Skip to content
digital garden
Back to Blog

Authentication Patterns in Next.js App Router

11 min read
nextjsauthsecuritytypescript

Authentication in the App Router is confusing — not because it's hard, but because there are too many places to do it. Middleware, layouts, pages, server actions, route handlers. Every tutorial picks one and pretends the others don't exist. There's no official "right way" because the right way is all of them, each handling a different layer of your defense.

I've shipped auth in several production Next.js apps at this point, and I keep landing on the same set of patterns. This is the reference I wish I had when I started: where to check auth, what each layer is responsible for, and how they fit together without redundant work.

The Auth Check Layers

Before writing any code, you need a mental model of how a request flows through a Next.js app:

Request → Middleware → Layout → Page → Server Action / Route Handler
           (Edge)     (Server)  (Server)      (Server)

The critical insight: no single layer is sufficient. Middleware can be bypassed during client-side navigation. Layouts don't re-execute on soft nav between sibling pages. Server actions are callable directly via POST. You need defense in depth.

LayerRuns OnGood ForLimitation
MiddlewareEdge, every matched requestFast redirects, blocking botsCan't access DB directly, limited runtime
LayoutServer, on hard navLoading user session for child treeDoesn't re-run on soft navigation between sibling routes
PageServer, each navigationPage-level authorization checksOnly protects that specific page
Server ActionServer, on invocationMutation authorizationMust be checked manually per action
Route HandlerServer, on requestAPI endpoint protectionMust be checked manually per handler

Pattern 1: Middleware Guards

Middleware is your first line of defense. It runs before the page renders, so unauthenticated users never see a flash of protected content. Keep it thin — validate a session cookie exists and hasn't expired, then let the server components do the heavy lifting.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
 
const PUBLIC_PATHS = ["/", "/login", "/signup", "/blog", "/api/auth"];
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Skip public routes
  if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) {
    return NextResponse.next();
  }
 
  const token = request.cookies.get("session")?.value;
 
  if (!token) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("callbackUrl", pathname);
    return NextResponse.redirect(loginUrl);
  }
 
  try {
    await jwtVerify(token, secret);
    return NextResponse.next();
  } catch {
    // Token expired or invalid — clear it and redirect
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("session");
    return response;
  }
}
 
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)"],
};

The matcher config is important — without it, middleware runs on every request including static assets. The callbackUrl parameter lets you redirect users back after login. Don't do heavy work here — middleware runs on the Edge runtime. You can't query your database or use Node-specific libraries. Verify the token signature and move on.

Pattern 2: Server Component Auth

This is where you actually load the user and make authorization decisions. I use a shared auth() helper that reads the session cookie and returns the user — or null if unauthenticated.

// lib/auth.ts
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
import { db } from "@/lib/db";
 
export interface Session {
  user: {
    id: string;
    email: string;
    name: string;
    role: "admin" | "member" | "viewer";
  };
  expires: Date;
}
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
 
export async function auth(): Promise<Session | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get("session")?.value;
 
  if (!token) return null;
 
  try {
    const { payload } = await jwtVerify(token, secret);
    const user = await db.user.findUnique({
      where: { id: payload.sub as string },
      select: { id: true, email: true, name: true, role: true },
    });
 
    if (!user) return null;
 
    return {
      user,
      expires: new Date(payload.exp! * 1000),
    };
  } catch {
    return null;
  }
}

Now use it in server components. The pattern is simple: call auth(), redirect if null, otherwise render with the user data.

// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { DashboardClient } from "./dashboard-client";
 
export default async function DashboardPage() {
  const session = await auth();
 
  if (!session) {
    redirect("/login");
  }
 
  // Role-based access
  if (session.user.role === "viewer") {
    redirect("/dashboard/readonly");
  }
 
  const data = await fetchDashboardData(session.user.id);
 
  return (
    <article>
      <section>
        <h2>Welcome back, {session.user.name}</h2>
        <DashboardClient user={session.user} initialData={data} />
      </section>
    </article>
  );
}

"But wait — didn't middleware already check auth?" Yes. Middleware catches the obvious case and redirects fast. The server component does the authoritative check: validating the token, hitting the database, checking roles. Defense in depth, not redundancy. And thanks to React's request memoization, calling auth() in both a layout and a page during the same request only runs the database query once.

Pattern 3: Protected Server Actions

Server actions are the most commonly under-protected layer. Developers add "use server" and forget that anyone can call these functions directly with a POST request. Every server action that mutates data must validate auth independently.

Here's a wrapper pattern that keeps it clean:

// lib/action-auth.ts
import { auth, Session } from "@/lib/auth";
 
type AuthenticatedAction<TInput, TOutput> = (
  session: Session,
  input: TInput
) => Promise<TOutput>;
 
export function protectedAction<TInput, TOutput>(
  action: AuthenticatedAction<TInput, TOutput>
) {
  return async (input: TInput): Promise<TOutput> => {
    const session = await auth();
    if (!session) throw new Error("Unauthorized");
    return action(session, input);
  };
}
 
export function adminAction<TInput, TOutput>(
  action: AuthenticatedAction<TInput, TOutput>
) {
  return async (input: TInput): Promise<TOutput> => {
    const session = await auth();
    if (!session) throw new Error("Unauthorized");
    if (session.user.role !== "admin") throw new Error("Forbidden");
    return action(session, input);
  };
}

Then wrap your actions — the session is injected automatically:

// app/dashboard/actions.ts
"use server";
 
import { protectedAction } from "@/lib/action-auth";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
 
export const updateProfile = protectedAction(
  async (session, data: { name: string; email: string }) => {
    await db.user.update({
      where: { id: session.user.id },
      data: { name: data.name, email: data.email },
    });
    revalidatePath("/dashboard");
    return { success: true };
  }
);

The wrapper gives you two things: guaranteed auth checking (you can't forget), and the session is injected so you always know who's performing the action.

Pattern 4: Protected Route Handlers

API route handlers follow the same principle. A higher-order function wraps the handler and injects the session:

// lib/route-auth.ts
import { NextRequest, NextResponse } from "next/server";
import { auth, Session } from "@/lib/auth";
 
type AuthenticatedHandler = (
  request: NextRequest,
  session: Session,
  context: { params: Promise<Record<string, string>> }
) => Promise<NextResponse>;
 
export function protectedRoute(handler: AuthenticatedHandler) {
  return async (
    request: NextRequest,
    context: { params: Promise<Record<string, string>> }
  ) => {
    const session = await auth();
    if (!session) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }
    return handler(request, session, context);
  };
}

Then your route handlers stay focused on business logic:

// app/api/projects/[id]/route.ts
import { protectedRoute } from "@/lib/route-auth";
import { db } from "@/lib/db";
 
export const GET = protectedRoute(async (request, session, context) => {
  const { id } = await context.params;
  const project = await db.project.findUnique({
    where: { id, organizationId: session.user.organizationId },
  });
 
  if (!project) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
  return NextResponse.json(project);
});
 
export const DELETE = protectedRoute(async (request, session, context) => {
  const { id } = await context.params;
  if (session.user.role !== "admin") {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }
 
  await db.project.delete({
    where: { id, organizationId: session.user.organizationId },
  });
  return NextResponse.json({ success: true });
});

If protectedRoute isn't wrapping a handler, it's immediately obvious in code review.

Pattern 5: Client-Side Auth Context

Client components need to know about the current user — for conditional rendering, optimistic updates, and UI personalization. The wrong way is to make a fetch call from the client on mount. The right way is to hydrate the user from the server.

// providers/auth-provider.tsx
"use client";
 
import { createContext, useContext } from "react";
 
interface User {
  id: string;
  email: string;
  name: string;
  role: "admin" | "member" | "viewer";
}
 
const AuthContext = createContext<{ user: User } | null>(null);
 
export function AuthProvider({ user, children }: { user: User; children: React.ReactNode }) {
  return <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>;
}
 
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within an AuthProvider");
  return context;
}

Mount it in your authenticated layout:

// app/dashboard/layout.tsx
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { AuthProvider } from "@/providers/auth-provider";
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <AuthProvider user={session.user}>
      {children}
    </AuthProvider>
  );
}

Now any client component in the dashboard tree can call useAuth() and get the user instantly — no loading state, no fetch, no waterfall.

Putting It Together

Here's a dashboard page that uses all five patterns:

// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { ProjectList } from "./project-list";
import { CreateProjectForm } from "./create-project-form";
 
export default async function DashboardPage() {
  const session = await auth(); // Pattern 2
  if (!session) redirect("/login");
 
  const projects = await db.project.findMany({
    where: { organizationId: session.user.organizationId },
    orderBy: { updatedAt: "desc" },
  });
 
  return (
    <article>
      <section>
        <h2>Your Projects</h2>
        {/* Pattern 5: user in context via layout AuthProvider */}
        <ProjectList projects={projects} />
      </section>
      <section>
        <h2>New Project</h2>
        {/* Pattern 3: submits to a protected server action */}
        <CreateProjectForm />
      </section>
    </article>
  );
}

The client component uses the auth context and calls a protected server action:

// app/dashboard/create-project-form.tsx
"use client";
 
import { useAuth } from "@/providers/auth-provider";
import { createProject } from "./actions";
import { useTransition } from "react";
 
export function CreateProjectForm() {
  const { user } = useAuth();
  const [isPending, startTransition] = useTransition();
 
  async function handleSubmit(formData: FormData) {
    startTransition(async () => {
      await createProject({
        name: formData.get("name") as string,
        description: formData.get("description") as string,
      });
    });
  }
 
  return (
    <form action={handleSubmit}>
      <input name="name" placeholder="Project name" required />
      <textarea name="description" placeholder="Description" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create Project"}
      </button>
      <p>Creating as {user.name}</p>
    </form>
  );
}

The createProject action uses the same protectedAction wrapper from Pattern 3. Middleware handled the redirect. The layout hydrated user context. The page loaded authorized data. The form submits to a protected action. Every layer does one job.

Common Mistakes

Checking auth only in middleware. Middleware is a convenience redirect, not a security boundary. A user whose session expires mid-session can still interact with cached pages. Always validate in server components and server actions too.

Storing tokens in localStorage. JWTs in localStorage are accessible to any JavaScript on the page, including XSS payloads. Use httpOnly, secure, sameSite=lax cookies instead:

// This is what your login API route should set
const response = NextResponse.json({ success: true });
response.cookies.set("session", token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  maxAge: 60 * 60 * 24 * 7, // 7 days
  path: "/",
});

Not revalidating after auth mutations. When a user logs in, logs out, or changes roles, call revalidatePath or revalidateTag to bust cached server component renders. Otherwise stale session data persists in the RSC payload.

Leaking user data to client components. When you pass the user object to a client component, that data is serialized into the RSC payload. Never include sensitive fields. Use a select clause in your database query or explicitly pick safe fields.

Assuming layouts re-run on every navigation. They don't. Navigating from /dashboard/projects to /dashboard/settings does not re-execute the shared layout. Each page must independently verify authorization for its own data.


The core principle is simple: never trust a single layer. Middleware redirects fast. Server components authorize data access. Server actions validate every mutation. Route handlers protect every endpoint. The auth context hydrates the client without extra fetches.

If you build these five patterns into your project structure early — the auth() helper, the protectedAction wrapper, the protectedRoute HOF, and the AuthProvider — adding new pages and features becomes mechanical. You just compose the pieces, and auth is handled by default rather than by remembering to add it.

Comments

No comments yet. Be the first to comment!