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
// 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()orredirect()
Comparison Table
| Layer | Runs in | Can query DB | Use case |
|---|---|---|---|
| Middleware | Edge (V8) | No | Broad route protection |
| Server Component | Node.js (SSR) | Yes | Page-level auth + data fetch |
| Route Handler | Node.js | Yes | API endpoint protection |
| Server Action | Node.js | Yes | Mutation protection |
| Layout | Node.js (SSR) | Yes | Section-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
// 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
// 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
// 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
// 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
josefor 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
// 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
// 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
// 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
"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 readyA concise answer to help you respond confidently on this topic during an interview.