Auth.js v5 trong Next.js 16: Hướng Dẫn Xác Thực với App Router (2026)

Cài đặt Auth.js v5 (next-auth@beta) trong Next.js 16 với App Router: OAuth Google/GitHub, Credentials provider, bảo vệ route bằng proxy.ts, đọc session trong Server Component và split config cho edge runtime.

Auth.js v5 Next.js 16: Hướng Dẫn 2026

Cập nhật: 19 tháng 6, 2026

Để thêm xác thực vào Next.js 16, hãy cài next-auth@beta (Auth.js v5), tạo file auth.ts ở thư mục gốc xuất ra { handlers, auth, signIn, signOut }, mount handlers trong app/api/auth/[...nextauth]/route.ts, và bảo vệ route bằng proxy.ts hoặc gọi auth() trực tiếp trong Server Component. Auth.js v5 (bản kế nhiệm chính thức của NextAuth.js) được thiết kế lại quanh App Router, Server Actions và edge runtime, nên nó là lựa chọn mặc định cho mọi dự án Next.js 16 mới trong năm 2026.

  • Auth.js v5 (next-auth@beta) là phiên bản hoàn toàn mới. Không còn NextAuth(options) kiểu cũ, mà export handlers, auth, signIn, signOut từ một file cấu hình duy nhất.
  • Trong Next.js 16, middleware được đổi tên thành proxy.ts. Auth.js v5 vẫn chạy ngon, chỉ cần wrap handler bằng auth được export ra từ file edge-compatible.
  • Đọc session trong Server Component bằng await auth(). Không cần getServerSession() như v4, và không cần truyền request object.
  • OAuth với Google, GitHub chỉ cần 2 dòng cấu hình. Credentials provider yêu cầu strategy jwt vì database session không dùng được với credentials.
  • Server Actions có thể gọi signIn() / signOut() trực tiếp, không cần fetch /api/auth/signin nữa, giảm round-trip rõ rệt.
  • Database adapter (Drizzle, Prisma, Neon) tách riêng khỏi cấu hình edge. Cần split auth.config.ts để middleware không bundle pg driver vào edge runtime.

Auth.js v5 là gì và có gì khác NextAuth v4?

Honestly, đây là câu hỏi đầu tiên ai cũng hỏi. Auth.js v5 là tên gọi mới của NextAuth.js bắt đầu từ phiên bản 5, phản ánh việc thư viện không còn chỉ phục vụ Next.js mà còn hỗ trợ SvelteKit, SolidStart và Express. Với Next.js 16, package vẫn cài qua next-auth nhưng bản v5 hiện ở dist-tag beta (đã ổn định production và là mặc định cho mọi dự án mới). Tôi đã chạy v5 trên production từ tháng 9 năm ngoái, và sau ba bản patch gần nhất thì gần như không còn lý do gì để giữ v4 nữa.

Điểm thay đổi lớn nhất nằm ở API. Trong v4 bạn có một file [...nextauth]/route.ts dài lê thê chứa NextAuth(authOptions), và mỗi nơi cần session lại phải import getServerSession(authOptions). Trong v5, bạn tạo một file auth.ts ở root rồi export bốn thứ: handlers, auth, signIn, signOut. Bất kỳ Server Component hay Server Action nào cũng chỉ cần import { auth } from "@/auth" rồi await auth(). Không truyền request, không truyền options.

Khía cạnhNextAuth v4Auth.js v5
Đọc session trên servergetServerSession(authOptions)await auth()
Cấu hình1 file route + import options1 file auth.ts root
MiddlewarewithAuth HOCauth wrap proxy.ts
Sign-in từ Server ActionKhông hỗ trợ trực tiếpawait signIn(...)
Edge compatibilityCần workaroundNative, split config
Account linkingMặc định tắtallowDangerousEmailAccountLinking

Nếu bạn đến từ v4, đừng kỳ vọng codemod tự động. Đường dẫn import, tên hàm, và cách compose middleware đều đổi. Phần lớn thời gian nâng cấp là viết lại middleware và xoá getServerSession.

Cài đặt và cấu hình ban đầu

Tạo dự án Next.js 16 nếu chưa có, rồi cài Auth.js v5:

pnpm add next-auth@beta
# hoặc
npm install next-auth@beta

Sinh secret cho JWT (bắt buộc trong production, và Next.js 16 sẽ throw error rõ ràng ở build time nếu thiếu):

npx auth secret
# tự động thêm AUTH_SECRET vào .env.local

Tạo file auth.ts ở thư mục gốc (cùng cấp với next.config.ts):

// auth.ts
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub, Google],
  session: { strategy: "jwt" },
  pages: {
    signIn: "/login",
  },
})

Sau đó mount handlers trong route group app/api/auth/[...nextauth]/route.ts:

// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth"
export const runtime = "nodejs" // hoặc "edge" tuỳ provider

Lưu ý: nếu bạn dùng database adapter (Drizzle, Prisma), hãy giữ runtime là nodejs. Edge runtime không hỗ trợ pg/mysql driver. Còn nếu chỉ dùng JWT session không database, edge runtime sẽ rút latency xuống còn khoảng 30-50ms ở vùng xa origin.

Khai báo biến môi trường trong .env.local:

AUTH_SECRET="..." # auth secret đã sinh ở trên
AUTH_GITHUB_ID="..."
AUTH_GITHUB_SECRET="..."
AUTH_GOOGLE_ID="..."
AUTH_GOOGLE_SECRET="..."

Auth.js v5 tự động đọc các biến theo convention AUTH_<PROVIDER>_ID/SECRET, nên bạn không cần truyền thủ công vào GitHub({ clientId, clientSecret }). Đây là một cải thiện quality-of-life mà v4 chưa có.

Thêm OAuth: Google và GitHub provider

OAuth là use-case dễ nhất với Auth.js v5. Bạn đã thêm provider trong auth.ts ở bước trước, nên phần còn lại chỉ là tạo OAuth app ở phía nhà cung cấp và đặt callback URL.

Với GitHub, truy cập GitHub Developer Settings, tạo OAuth App mới, đặt Authorization callback URL: http://localhost:3000/api/auth/callback/github. Với Google, vào Google Cloud Console, OAuth consent screen, tạo OAuth Client ID web app, callback URL: http://localhost:3000/api/auth/callback/google. Chi tiết thêm thì xem Auth.js OAuth docs.

Tạo nút đăng nhập trong Server Component sử dụng Server Action. Đây là pattern mà tôi thấy gọn nhất trong App Router:

// app/login/page.tsx
import { signIn } from "@/auth"

export default function LoginPage() {
  return (
    <div className="flex flex-col gap-3 max-w-sm mx-auto mt-20">
      <form
        action={async () => {
          "use server"
          await signIn("github", { redirectTo: "/dashboard" })
        }}
      >
        <button type="submit">Đăng nhập với GitHub</button>
      </form>

      <form
        action={async () => {
          "use server"
          await signIn("google", { redirectTo: "/dashboard" })
        }}
      >
        <button type="submit">Đăng nhập với Google</button>
      </form>
    </div>
  )
}

Khi user submit form, Server Action chạy signIn("github"), redirect tới GitHub OAuth, rồi quay về /dashboard sau khi cấp quyền. Không cần client-side onClick, không cần fetch endpoint. Đó là sức mạnh mà Server Actions mang lại cho auth flow, và là lý do tôi đẩy team chuyển khỏi v4 sớm hơn dự định. Để hiểu sâu hơn về cách compose Server Actions với validation, hãy đọc hướng dẫn useActionState với Zod.

Credentials provider với email/password

Nhiều dự án vẫn cần login truyền thống bằng email/password (đặc biệt khi backend đã có user table sẵn). Credentials provider trong v5 hoạt động khác v4 một chút: bắt buộc session strategy là jwt, và bạn phải tự verify password.

// auth.ts (mở rộng)
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"
import { z } from "zod"
import { db } from "@/lib/db"
import { users } from "@/lib/db/schema"
import { eq } from "drizzle-orm"

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

export const { handlers, auth, signIn, signOut } = NextAuth({
  session: { strategy: "jwt" },
  providers: [
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials) => {
        const parsed = loginSchema.safeParse(credentials)
        if (!parsed.success) return null

        const { email, password } = parsed.data
        const [user] = await db
          .select()
          .from(users)
          .where(eq(users.email, email))
          .limit(1)

        if (!user || !user.passwordHash) return null

        const valid = await bcrypt.compare(password, user.passwordHash)
        if (!valid) return null

        return { id: user.id, email: user.email, name: user.name }
      },
    }),
  ],
})

Một số điểm rất dễ sai khi mới làm:

  • Trả về null, không throw. Throw từ authorize sẽ rò rỉ stack trace ra client. Trả về null đơn thuần để Auth.js show generic error.
  • Không bao giờ trả về plaintext password trong object user. Object trả về sẽ được serialize vào JWT. Chỉ trả các field bạn muốn lộ ra cho session thôi.
  • Validate input với Zod trước khi query DB. Nếu input không hợp lệ, return null sớm để tránh enumeration attack qua timing.

Bạn vẫn cần Server Action wrap signIn("credentials", ...) để xử lý error gracefully:

// app/login/actions.ts
"use server"
import { signIn } from "@/auth"
import { AuthError } from "next-auth"

export async function loginAction(_: unknown, formData: FormData) {
  try {
    await signIn("credentials", {
      email: formData.get("email"),
      password: formData.get("password"),
      redirectTo: "/dashboard",
    })
  } catch (error) {
    if (error instanceof AuthError) {
      return { error: "Email hoặc mật khẩu không đúng" }
    }
    throw error // redirect error: re-throw để Next.js xử lý
  }
}

Quan trọng: redirect trong Next.js 16 vẫn dùng cơ chế throw error, nên bạn phải throw error lại nếu nó không phải AuthError. Tôi đã mất nửa ngày debug vụ này. Login thành công nhưng không redirect, vì try/catch nuốt luôn redirect error. Đúng kiểu lỗi mà bạn chỉ học được sau khi gặp một lần.

Bảo vệ route bằng proxy.ts trong Next.js 16

Next.js 16 đổi tên middleware.ts thành proxy.ts. Về mặt API thì gần giống, nhưng có vài thay đổi đáng chú ý về runtime. Nếu bạn còn đang chuyển đổi, hãy đọc hướng dẫn chuyển middleware sang proxy.ts trước khi tiếp tục.

Để dùng auth trong proxy.ts, bạn cần tách cấu hình thành hai file, vì proxy chạy ở edge và không thể bundle database adapter:

// auth.config.ts - edge-safe, không import db
import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google"
import type { NextAuthConfig } from "next-auth"

export default {
  providers: [GitHub, Google],
  pages: { signIn: "/login" },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user
      const isOnDashboard = nextUrl.pathname.startsWith("/dashboard")

      if (isOnDashboard) return isLoggedIn
      if (isLoggedIn && nextUrl.pathname === "/login") {
        return Response.redirect(new URL("/dashboard", nextUrl))
      }
      return true
    },
  },
} satisfies NextAuthConfig
// auth.ts - full config với adapter
import NextAuth from "next-auth"
import { DrizzleAdapter } from "@auth/drizzle-adapter"
import { db } from "@/lib/db"
import authConfig from "./auth.config"

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  session: { strategy: "jwt" },
  ...authConfig,
})
// proxy.ts - Next.js 16 thay thế middleware.ts
import NextAuth from "next-auth"
import authConfig from "./auth.config"

export const { auth: proxy } = NextAuth(authConfig)

export default proxy((req) => {
  // Logic bổ sung nếu cần, req.auth có sẵn user
})

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}

Callback authorized chạy ở mọi request trùng matcher. Return true cho phép, false redirect về signIn page, hoặc trả Response.redirect() cho custom redirect. Logic protect mọi route /dashboard/* nằm trong một chỗ duy nhất, không scatter auth() check khắp từng page.

Đọc session trong Server Component và Server Action

Đây là phần Auth.js v5 sướng nhất so với v4. Trong bất kỳ Server Component nào, bạn chỉ cần:

// app/dashboard/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"

export default async function DashboardPage() {
  const session = await auth()

  if (!session?.user) {
    redirect("/login")
  }

  return (
    <div>
      <h1>Xin chào, {session.user.name}</h1>
      <p>Email: {session.user.email}</p>
    </div>
  )
}

Trong Server Action, syntax giống hệt:

// app/posts/actions.ts
"use server"
import { auth } from "@/auth"
import { db } from "@/lib/db"
import { posts } from "@/lib/db/schema"

export async function createPost(formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) {
    throw new Error("Unauthorized")
  }

  await db.insert(posts).values({
    title: formData.get("title") as string,
    authorId: session.user.id,
  })
}

Trong v4, bạn phải nhớ truyền authOptions mỗi lần getServerSession(). Quên một lần là session thành null ngầm. V5 dùng module-level config, không có chỗ để quên. Kết hợp với pattern CRUD đầy đủ, bạn có thể tham khảo cách tích hợp với database trong tutorial Drizzle ORM + Neon PostgreSQL.

Database adapter với Drizzle và edge split

Với JWT session không có database, user vẫn login được nhưng không lưu accounts, sessions, verification tokens vào DB. Đủ cho prototype, không đủ cho production cần audit log. Với database adapter, Auth.js sẽ tự tạo và quản lý các bảng users, accounts, sessions, verificationTokens.

Cài Drizzle adapter:

pnpm add @auth/drizzle-adapter drizzle-orm postgres

Schema cần các bảng theo chuẩn Auth.js, hãy copy từ Drizzle adapter docs để tránh sai field name. Một số field nhạy cảm với case: emailVerified, providerAccountId. Sai một ký tự là adapter sẽ silently fail tạo user.

// lib/db/schema.ts (rút gọn)
import { pgTable, text, timestamp, primaryKey } from "drizzle-orm/pg-core"

export const users = pgTable("user", {
  id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
  name: text("name"),
  email: text("email").notNull().unique(),
  emailVerified: timestamp("emailVerified", { mode: "date" }),
  image: text("image"),
})

export const accounts = pgTable(
  "account",
  {
    userId: text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
    type: text("type").notNull(),
    provider: text("provider").notNull(),
    providerAccountId: text("providerAccountId").notNull(),
    refresh_token: text("refresh_token"),
    access_token: text("access_token"),
    expires_at: timestamp("expires_at"),
    token_type: text("token_type"),
    scope: text("scope"),
    id_token: text("id_token"),
    session_state: text("session_state"),
  },
  (acc) => [primaryKey({ columns: [acc.provider, acc.providerAccountId] })]
)

Khi đã tách auth.config.ts (edge-safe) khỏi auth.ts (full config), bạn có thể bật adapter mà không vỡ proxy.ts. Đây là lý do split cấu hình là bắt buộc. Nếu bạn để adapter trong auth.config.ts, build sẽ throw error Module not found: Can't resolve 'pg-native' khi compile proxy cho edge runtime.

Lỗi thường gặp khi nâng cấp từ v4

Sau khi giúp ba team production migrate v4 sang v5, đây là những lỗi tôi gặp đi gặp lại:

  • "UntrustedHost" error trên Vercel preview deployment. Mỗi preview có URL khác nhau, nên Auth.js v5 (mặc định strict) reject. Fix bằng trustHost: true trong config, nhưng chỉ làm khi bạn chắc chắn deploy ở môi trường tin cậy (Vercel, AWS).
  • Session là null dù đã login. Thường do cookie bị set với Domain sai, hoặc AUTH_URL không khớp với hostname thực tế. Kiểm tra AUTH_URL trong production và xoá NEXTAUTH_URL cũ.
  • "Cannot find module 'next-auth/providers/...'" sau khi migrate. V5 cấu trúc export khác. Import path là next-auth/providers/github, không phải next-auth/providers/github.js.
  • OAuth callback redirect về /api/auth/error?error=Configuration. Gần như luôn là provider credentials sai, hoặc callback URL chưa whitelist ở phía OAuth app. Bật debug: true trong config để xem log chi tiết.
  • JWT decode fail sau khi đổi AUTH_SECRET. Mọi session cũ sẽ invalid (đây là behavior đúng), nhưng nhớ schedule rotate secret vào lúc traffic thấp.

Câu hỏi thường gặp

NextAuth còn được dùng trong năm 2026 không?

Có, nhưng dưới tên Auth.js. Package vẫn được publish là next-auth trên npm và được team Vercel duy trì. Phiên bản v5 đã ổn định production, và là lựa chọn auth phổ biến nhất cho Next.js 16, ngoài Clerk và Lucia.

Sự khác biệt giữa Auth.js v4 và v5 là gì?

V5 thay getServerSession() bằng await auth(), hỗ trợ Server Actions native (await signIn() trong action), tự đọc env theo convention AUTH_*, và edge-compatible mà không cần workaround. API cũ không tương thích, và không có codemod tự động.

Làm sao bảo vệ route trong Next.js 16?

Có hai cách. Cách scale tốt nhất là dùng proxy.ts với callback authorized của Auth.js, kiểm tra ở edge trước khi request vào app. Cách thứ hai là gọi await auth() trực tiếp trong Server Component và redirect("/login") nếu không có session, dùng cho protect lẻ tẻ.

Có thể dùng Auth.js với Server Components không?

Có, đây là use-case chính của v5. Bạn import { auth } from "@/auth"await auth() trong async Server Component. Không cần SessionProvider, không cần truyền request, và session không leak vào client bundle.

Tại sao Credentials provider phải dùng JWT session?

Vì Credentials không có "account" entity tương ứng trong adapter, tức là không có provider OAuth account để link với session row. Auth.js v5 throw lỗi khi bạn dùng strategy database với Credentials. Workaround: dùng JWT, hoặc tự ghi session row trong callback signIn.

Ben Howard
Về Tác Giả Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.