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.
Recipe
- Install Auth.js v5 beta:
npm install next-auth@beta - Generate a secret and add it to
.env.local:npx auth secret - Create
auth.tsat the project root exporting configured handlers,auth,signIn, andsignOut. - Create the route handler at
app/api/auth/[...nextauth]/route.tsthat re-exports the handlers. - Add OAuth credentials (e.g.,
AUTH_GITHUB_ID,AUTH_GITHUB_SECRET) to.env.local. - Create
middleware.tsthat uses theauthexport to protect routes.
Working Example
.env.local
AUTH_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.ts
export \{ GET, POST \} from "@/auth";Wait — in v5 you export from the handlers object instead:
import \{ handlers \} from "@/auth";
export const \{ GET, POST \} = handlers;middleware.ts
export \{ auth as middleware \} from "@/auth";
export const config = \{
matcher: ["/dashboard/:path*"],
\};app/dashboard/page.tsx
import \{ 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>
);
\}Deep Dive
How It Works
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.
Variations
- Multiple providers: add
Google,Credentials,Email, etc. to theprovidersarray. - Database sessions: install
@auth/prisma-adapter, passadapter: PrismaAdapter(prisma), and setsession: \{ strategy: "database" \}. - Custom sign-in page: set
pages: \{ signIn: "/login" \}in the NextAuth config and build your own/loginpage that callssignIn(). - Role-based access: mutate the session in the
jwtandsessioncallbacks to include aroleclaim, then check it in middleware or Server Components. auth()in RSC vsuseSession()in client: server code usesawait auth(); client code wraps the app in<SessionProvider>and callsuseSession().- Edge-compatible middleware: keep
auth.config.tsfree of Node-only adapters and import only the lightweight config in middleware.
TypeScript Notes
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().
Gotchas
AUTH_SECRETmust be long enough. Auth.js v5 requires 32+ characters. Generate withnpx auth secret— don't hand-type one.- Middleware runs on the Edge runtime. You cannot use Node.js APIs (
fs,crypto.randomBytesin legacy form, database drivers). Keep heavy logic out ofmiddleware.ts. - Matcher excludes static assets. The default matcher can match
/_next/staticand images if you're not careful. Use a negative lookahead like/((?!api|_next/static|_next/image|favicon.ico).*). - Session strategy limits storage. JWT sessions live in a cookie with a ~4KB limit. Don't dump the whole user object into the token. Use database sessions or store an ID and fetch on demand.
- OAuth callback URL mismatch. GitHub/Google OAuth apps must list the exact callback URL, e.g.,
https://yourapp.com/api/auth/callback/github. A trailing slash or wrong protocol breaks sign-in with a vague error. - Dev vs prod callback URLs. You typically need two OAuth apps (or two callback URLs) — one for
http://localhost:3000and one for production. auth.tsat project root, notapp/. Putting it insideapp/makes Next.js treat it as a route. Keep it at the project root or underlib/and alias accordingly.
Alternatives
| 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 |
FAQs
Where should auth.ts live?
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.
How do I generate AUTH_SECRET?
Run npx auth secret. It writes a cryptographically random 32+ character value to .env.local automatically. Don't reuse secrets across environments.
JWT or database sessions?
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.
How do I add a custom sign-in page?
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.
How do I read the session in a Server Component?
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.
Gotcha: my middleware imports a database adapter and breaks at build time
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.
Gotcha: OAuth works locally but fails in production
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.
How do I add role-based access control?
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;
\},
\}TypeScript: how do I add custom fields to the Session type?
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.
TypeScript: session.user is possibly undefined — what gives?
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.
How do I sign out?
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.
Should I use Auth.js or Clerk?
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.