React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

authauthjsnextauthoauthgithubmiddlewaresession

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

  1. Install Auth.js v5 beta:
    npm install next-auth@beta
  2. Generate a secret and add it to .env.local:
    npx auth secret
  3. Create auth.ts at the project root exporting configured handlers, auth, signIn, and signOut.
  4. Create the route handler at app/api/auth/[...nextauth]/route.ts that re-exports the handlers.
  5. Add OAuth credentials (e.g., AUTH_GITHUB_ID, AUTH_GITHUB_SECRET) to .env.local.
  6. Create middleware.ts that uses the auth export 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 the providers array.
  • Database sessions: install @auth/prisma-adapter, pass adapter: PrismaAdapter(prisma), and set session: \{ strategy: "database" \}.
  • Custom sign-in page: set pages: \{ signIn: "/login" \} in the NextAuth config and build your own /login page that calls signIn().
  • Role-based access: mutate the session in the 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().
  • Edge-compatible middleware: keep auth.config.ts free 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

  1. AUTH_SECRET must be long enough. Auth.js v5 requires 32+ characters. Generate with npx auth secret — don't hand-type one.
  2. Middleware runs on the Edge runtime. You cannot use Node.js APIs (fs, crypto.randomBytes in legacy form, database drivers). Keep heavy logic out of middleware.ts.
  3. Matcher excludes static assets. The default matcher can match /_next/static and images if you're not careful. Use a negative lookahead like /((?!api|_next/static|_next/image|favicon.ico).*).
  4. 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.
  5. 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.
  6. Dev vs prod callback URLs. You typically need two OAuth apps (or two callback URLs) — one for http://localhost:3000 and one for production.
  7. 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.

Alternatives

ToolStyleBest For
Auth.js (NextAuth v5)OSS, framework-integratedMost Next.js apps, OAuth flows
ClerkPaid SaaSPolished UI, fast setup, orgs/teams
Lucia AuthLightweight DIYFull control, learning, custom flows
Supabase AuthBundled with Supabase DBApps already using Supabase
KindePaid SaaSFeature flags + auth combined
Custom JWTRoll-your-ownSpecialized 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.