Skip to main content

Authentication patterns in Next.js

Authentication patterns in Next.js are strategies for verifying user identity at different points in the request lifecycle: edge (Middleware), page render (Server Components), API calls (Route Handlers), and mutations (Server Actions).

Theory

TL;DR

  • Think of it like airport security: Middleware checks everyone at the entrance before they board (every request), Server Components verify the boarding pass at the gate (page render), Route Handlers check at each service counter (API call)
  • Middleware is the fastest layer but is also the most limited: no database queries, no Node.js modules
  • Server Components can fetch user data and check auth in one SSR pass, no client round-trip needed
  • Route Handlers and Server Actions are public network endpoints and need their own explicit getSession() call
  • Combine layers: Middleware blocks early, Server Components fetch data, Server Actions guard mutations

Quick Example

tsx
// middleware.ts - protects /dashboard/* at the edge import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; const publicPaths = ["/", "/login", "/register", "/api/auth"]; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; if (publicPaths.some(path => pathname.startsWith(path))) { return NextResponse.next(); // let public paths through } const token = request.cookies.get("auth-token")?.value; if (!token) { const loginUrl = new URL("/login", request.url); loginUrl.searchParams.set("callbackUrl", pathname); // preserve destination return NextResponse.redirect(loginUrl); } return NextResponse.next(); } export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], };

Middleware runs in Vercel's Edge Runtime (V8 isolates), not full Node.js. It reads headers and cookies via NextRequest and returns a redirect before routing ever hits your pages.

Key Difference

Middleware intercepts requests before any page code runs, making it fast but limited. No Node.js APIs, no database queries. Server Components run during SSR in Node.js and can call your database directly, but only when that specific page renders. Route Handlers are per-endpoint API guards. Server Actions are also public network endpoints and need their own checks, since they can be called directly from any client.

When to Use

  • Protecting entire app sections (admin panel, dashboard) - Middleware
  • Fetching user data for a specific page - Server Component
  • Securing API endpoints - Route Handler
  • Validating before database writes - Server Action
  • OAuth flows (Google, GitHub) - Route Handler via NextAuth.js
  • Role-based page access - Server Component with notFound() or redirect()

Comparison Table

LayerRuns inCan query DBUse case
MiddlewareEdge (V8)NoBroad route protection
Server ComponentNode.js (SSR)YesPage-level auth + data fetch
Route HandlerNode.jsYesAPI endpoint protection
Server ActionNode.jsYesMutation protection
LayoutNode.js (SSR)YesSection-wide auth

How It Works Internally

Middleware executes in Vercel's Edge Runtime before routing to origin. It can read NextRequest (headers, cookies, URL) but cannot use Node.js modules like pg, fs, or full ORM clients. Server Components run in Node.js during SSR and access cookies via cookies() from next/headers. Auth state is injected directly into the RSC payload, so no separate client request is needed.

One thing I've seen trip up teams: Middleware runs on every matched request, including API routes. If your matcher is too broad, you add edge latency to every call including health checks.

Common Mistakes

Mistake: matching static assets in Middleware

tsx
// Wrong - auth runs on every CSS, JS, and image file export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], }; // Better - protect only what needs protection export const config = { matcher: ["/dashboard/:path*", "/profile/:path*", "/admin/:path*"], };

The broad negative-lookahead matcher appears in Next.js docs for i18n scenarios. For auth specifically, explicit path matching avoids unnecessary edge overhead.

Mistake: no callbackUrl on redirect

tsx
// Wrong - user loses their destination after login return NextResponse.redirect(new URL("/login", req.url)); // Correct - user lands back where they wanted to go const loginUrl = new URL("/login", req.url); loginUrl.searchParams.set("callbackUrl", req.nextUrl.pathname); return NextResponse.redirect(loginUrl);

Mistake: database queries in Middleware

tsx
// Wrong - Prisma, pg, Drizzle all fail in edge runtime const user = await prisma.user.findUnique({ where: { id: session.sub } }); // Correct - use jose for JWT verification (edge-compatible) import { jwtVerify } from "jose"; await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET!));

Mistake: relying on client-side auth checks

tsx
// Wrong (Client Component) - user can manipulate this "use client"; if (!document.cookie.includes("auth-token")) router.push("/login"); // Correct - check in Server Component or Middleware const session = await getSession(); // server-side only if (!session) redirect("/login");

Mistake: skipping auth in Server Actions

Server Actions are public endpoints. Calling updateProfile() from the client goes through the same network as any API route. Always call getSession() at the top of every Server Action that touches user data. Never silently skip it.

Real-World Usage

  • NextAuth.js (Auth.js v5) - Middleware + Route Handlers for OAuth, auth() helper in Server Components
  • Clerk - Middleware for role-based access, common in T3 Stack apps
  • Supabase - Server Components with createServerClient() for row-level security
  • Lucia - custom sessions via Route Handlers in open-source starters
  • Custom JWT - Middleware with jose for edge-compatible token verification

Follow-Up Questions

Q: How does Middleware differ from getSession() in a Server Component?
A: Middleware runs at the edge before the page loads and cannot use Node.js modules. getSession() in a Server Component runs in Node.js during SSR and can query your database. Use Middleware to block requests early, Server Components to fetch data.

Q: What happens to static pages when Middleware auth is active?
A: Static pages bypass Middleware unless they match your matcher config. Always exclude /_next/static and /_next/image from auth matchers, or every static asset request goes through the edge check.

Q: How do you handle role-based access in Server Components?
A: Fetch the session, check session.user.role, then call redirect() or notFound(). For example, if (session.role !== "admin") notFound() returns a 404 instead of leaking that the page exists at all.

Q: Why can't you query a database in Middleware?
A: Edge Runtime runs in V8 isolates without Node.js APIs. Libraries like pg, Prisma, and Drizzle depend on Node modules that don't exist at the edge. Use JWTs verified with jose, or external services like Upstash Redis.

Q: In App Router, how does auth interact with parallel routes?
A: Middleware applies before any intercepting routes run. Parallel routes inherit the session from their parent Server Component layout. With Auth.js v5's auth() helper, you fetch session once in the layout and pass it into each slot, avoiding repeated calls across parallel segments.

Q: How do you migrate Pages Router auth to App Router?
A: Replace _app.getInitialProps session logic with Middleware. Replace getServerSideProps session checks with cookies() inside Server Components. The getServerSession() pattern maps directly to auth() in Auth.js v5.

Examples

Middleware with callbackUrl

tsx
// middleware.ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; export function middleware(request: NextRequest) { const token = request.cookies.get("auth-token")?.value; const isLoginPage = request.nextUrl.pathname.startsWith("/login"); if (!token && !isLoginPage) { const loginUrl = new URL("/login", request.url); loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname); return NextResponse.redirect(loginUrl); } return NextResponse.next(); } export const config = { matcher: ["/dashboard/:path*", "/profile/:path*", "/admin/:path*"], };

The explicit matcher protects three sections without touching static assets. After login, the app reads callbackUrl from the query string and redirects the user to their original destination.

Server Component with auth and data fetch in one pass

tsx
// lib/auth.ts import { cookies } from "next/headers"; export async function getSession() { const cookieStore = await cookies(); const token = cookieStore.get("auth-token")?.value; if (!token) return null; try { return await verifyToken(token); // returns user object } catch { return null; } } // app/dashboard/page.tsx import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; export default async function DashboardPage() { const session = await getSession(); if (!session) redirect("/login"); const stats = await fetchUserStats(session.user.id); // one pass, no extra round-trip return ( <div> <h1>Welcome, {session.user.name}</h1> <StatsPanel data={stats} /> </div> ); }

The Server Component checks auth and fetches page data in a single pass. No client round-trip, no loading state for the auth check.

NextAuth.js v5 with built-in Middleware

tsx
// auth.ts import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import Credentials from "next-auth/providers/credentials"; export const { auth, handlers, signIn, signOut } = NextAuth({ providers: [ GitHub, Credentials({ credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, authorize: async (credentials) => { const user = await getUserByEmail(credentials.email as string); if (!user) return null; const valid = await verifyPassword(credentials.password as string, user.password); return valid ? user : null; }, }), ], }); // app/api/auth/[...nextauth]/route.ts import { handlers } from "@/auth"; export const { GET, POST } = handlers; // middleware.ts - Auth.js v5 exports its own middleware export { auth as middleware } from "@/auth"; export const config = { matcher: ["/dashboard/:path*"], };

Auth.js v5 exports a Middleware function you can use directly or wrap with custom logic. The auth() helper works identically in Server Components and Server Actions with no additional setup.

Server Action with auth guard

tsx
"use server"; import { getSession } from "@/lib/auth"; import { revalidatePath } from "next/cache"; export async function updateProfile(formData: FormData) { const session = await getSession(); if (!session) { throw new Error("Unauthorized"); // never fail silently } const name = formData.get("name") as string; await db.update(users) .set({ name }) .where(eq(users.id, session.user.id)); revalidatePath("/profile"); }

Server Actions are public network endpoints. Without getSession() at the top, any request that looks like a valid action call can trigger the mutation.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?