Next.js + Auth.js v5
Add authentication to a Next.js 15 app with Auth.js v5 (formerly NextAuth), including GitHub OAuth and session-based route protection.
Search across all documentation pages
Add authentication to a Next.js 15 app with Auth.js v5 (formerly NextAuth), including GitHub OAuth and session-based route protection.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
npm install next-auth@beta.env.local:
npx auth secretauth.ts at the project root exporting configured handlers, auth, signIn, and signOut.app/api/auth/[...nextauth]/route.ts that re-exports the handlers.AUTH_GITHUB_ID, AUTH_GITHUB_SECRET) to .env.local.middleware.ts that uses the auth export to protect routes..env.localAUTH_SECRET=<32+ char string from `npx auth secret`>
AUTH_GITHUB_ID=<github oauth app client id>
AUTH_GITHUB_SECRET=<github oauth app client secret>
auth.ts (project root)import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const \{ handlers, auth, signIn, signOut \} = NextAuth(\{
providers: [GitHub],
session: \{ strategy: "jwt" \},
callbacks: \{
authorized: async (\{ auth \}) => !!auth,
\},
\});app/api/auth/[...nextauth]/route.tsexport \{ GET, POST \} from "@/auth";Wait - in v5 you export from the handlers object instead:
import \{ handlers \} from "@/auth";
export const \{ GET, POST \} = handlers;middleware.tsexport \{ auth as middleware \} from "@/auth";
export const config = \{
matcher: ["/dashboard/:path*"],
\};app/dashboard/page.tsximport \{ auth, signOut \} from "@/auth";
export default async function DashboardPage() \{
const session = await auth();
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="mt-4">Welcome, \{session?.user?.name\}</p>
<form
action=\{async () => \{
"use server";
await signOut();
\}\}
>
<button type="submit" className="mt-4 rounded bg-black px-4 py-2 text-white">
Sign out
</button>
</form>
</main>
);
\}app/page.tsx (sign-in button)import \{ auth, signIn \} from "@/auth";
export default async function Home() \{
const session = await auth();
if (session?.user) \{
return <a href="/dashboard">Go to dashboard</a>;
\}
return (
<form
action=\{async () => \{
"use server";
await signIn("github", \{ redirectTo: "/dashboard" \});
\}\}
>
<button type="submit">Sign in with GitHub</button>
</form>
);
\}Auth.js v5 centralizes configuration in one root auth.ts file, which exports all the functions you need elsewhere. The auth() function is isomorphic - it reads the session from cookies in Server Components, Server Actions, Route Handlers, and middleware. The middleware import export \{ auth as middleware \} turns Auth.js into a matcher-aware guard that redirects unauthenticated users to the sign-in page.
Under the hood, Auth.js sets a signed, encrypted JWT cookie by default (session.strategy: "jwt"). You can switch to database sessions by adding a Prisma/Drizzle adapter.
Google, Credentials, Email, etc. to the providers array.@auth/prisma-adapter, pass adapter: PrismaAdapter(prisma), and set session: \{ strategy: "database" \}.pages: \{ signIn: "/login" \} in the NextAuth config and build your own /login page that calls signIn().jwt and session callbacks to include a role claim, then check it in middleware or Server Components.auth() in RSC vs useSession() in client: server code uses await auth(); client code wraps the app in <SessionProvider> and calls useSession().auth.config.ts free of Node-only adapters and import only the lightweight config in middleware.Extend the Session interface using module augmentation so your custom claims are typed everywhere:
// types/next-auth.d.ts
import \{ DefaultSession \} from "next-auth";
declare module "next-auth" \{
interface Session \{
user: \{
id: string;
role: "admin" | "user";
\} & DefaultSession["user"];
\}
\}After this, session.user.role is strongly typed everywhere you call auth().
AUTH_SECRET must be long enough. Auth.js v5 requires 32+ characters. Generate with npx auth secret - don't hand-type one.fs, crypto.randomBytes in legacy form, database drivers). Keep heavy logic out of middleware.ts./_next/static and images if you're not careful. Use a negative lookahead like /((?!api|_next/static|_next/image|favicon.ico).*).https://yourapp.com/api/auth/callback/github. A trailing slash or wrong protocol breaks sign-in with a vague error.http://localhost:3000 and one for production.auth.ts at project root, not app/. Putting it inside app/ makes Next.js treat it as a route. Keep it at the project root or under lib/ and alias accordingly.| Tool | Style | Best For |
|---|---|---|
| Auth.js (NextAuth v5) | OSS, framework-integrated | Most Next.js apps, OAuth flows |
| Clerk | Paid SaaS | Polished UI, fast setup, orgs/teams |
| Lucia Auth | Lightweight DIY | Full control, learning, custom flows |
| Supabase Auth | Bundled with Supabase DB | Apps already using Supabase |
| Kinde | Paid SaaS | Feature flags + auth combined |
| Custom JWT | Roll-your-own | Specialized requirements |
At the project root (same level as next.config.js) or under lib/auth.ts. Do not put it inside the app/ directory - Next.js will interpret files there as routes.
Run npx auth secret. It writes a cryptographically random 32+ character value to .env.local automatically. Don't reuse secrets across environments.
JWT is stateless, fast, and the default - perfect for most apps. Database sessions require an adapter but give you instant revocation, larger storage, and easy cross-device session listing. Pick database sessions for apps with logout-everywhere or admin kick-user features.
Set pages: \{ signIn: "/login" \} in the NextAuth config, then build app/login/page.tsx that renders your own UI and calls signIn("github") from a Server Action.
import \{ auth \} from "@/auth";
const session = await auth();
if (!session?.user) redirect("/login");In Client Components, wrap the app in <SessionProvider> and call useSession() instead.
The middleware bundle uses the Edge runtime, which cannot include Node-only code like the Prisma adapter. Split your config: put the lightweight provider list in auth.config.ts and import only that in middleware.ts. Keep the adapter in the full auth.ts that Server Components use.
Almost always a callback URL mismatch. Your GitHub (or Google) OAuth app must list the exact production URL: https://yourapp.com/api/auth/callback/github. Watch for trailing slashes, http vs https, and the www subdomain.
Use the jwt callback to stamp the role onto the token, then mirror it into the session:
callbacks: \{
async jwt(\{ token, user \}) \{
if (user) token.role = user.role;
return token;
\},
async session(\{ session, token \}) \{
session.user.role = token.role as "admin" | "user";
return session;
\},
\}Use module augmentation in types/next-auth.d.ts:
declare module "next-auth" \{
interface Session \{
user: \{ id: string; role: string \} & DefaultSession["user"];
\}
\}Make sure types/next-auth.d.ts is included by your tsconfig.json.
auth() returns Session | null, and even when not null, user is optional. Always narrow:
const session = await auth();
if (!session?.user) redirect("/login");After that check, session.user is narrowed to the defined type.
Call signOut() from Auth.js. In a Server Component you wrap it in a Server Action:
<form action=\{async () => \{ "use server"; await signOut(); \}\}>
<button type="submit">Sign out</button>
</form>In a Client Component, import signOut from next-auth/react and call it directly.
Clerk is faster to set up and includes polished UI, orgs, and MFA out of the box - but it's paid. Auth.js is free and fully customizable but requires more wiring. Pick Clerk if speed-to-market and features matter more than cost; pick Auth.js if you want full control or zero vendor lock-in.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥