Skip to content

Authentication in Next.js

Aman Kumar Singh8 min read
Part 26 of 50From the React & Next.js Complete Guide series
Authentication in Next.js — article by Aman Kumar Singh

In Building a UI with shadcn/ui, the forms and dialogs we wired up assumed a logged-in user was already available somewhere in the component tree. This article builds out that assumption for real. It covers how a Next.js app actually knows who's making a request, and how that identity gets carried through Server Components, Client Components, Route Handlers, and Server Actions without four different auth checks quietly drifting out of sync.

This is part of the React & Next.js Complete Guide series. I'm assuming you're comfortable with the App Router's server/client split from earlier articles here. That split is exactly what makes authentication in Next.js harder than in a typical single-page app.

The scope here is narrower than "how to build a login system." Issuing tokens, hashing passwords, storing sessions in Redis: that's backend work. It doesn't change much whether the frontend is Next.js or anything else. What's specific to Next.js is the surface area: a Server Component, a Client Component, and a Route Handler each read the session differently, and a mistake in any one of them creates a real security gap.

Authentication in Next.js is a distribution problem, not a login-form problem

The login form itself is the easy part. The hard part is that a single logged-in state now needs to be correct in at least four different places: Server Components that render on the request, Client Components that hydrate in the browser, Route Handlers that act as your API layer, and Server Actions that run mutations. Each of these executes in a different context, and none of them share memory with each other.

A common mistake is treating the session as something you check once at the top and pass down as a prop. That works for rendering, but a Server Action invoked from a deeply nested Client Component doesn't run inside your page's render tree; it's a separate server invocation triggered by a form submission. If the only place you verify the session is in the page component, every Server Action on that page implicitly trusts that whoever called it already passed a check it never actually ran.

The fix is to make session reads cheap and consistent enough that you do them everywhere, rather than doing them once and threading the result through props. In practice this means a single auth() (or equivalent) helper that works identically whether it's called from a Server Component, a Route Handler, or a Server Action, backed by a cookie that's already on every one of those requests.

Picking a session strategy: JWT cookies vs a database-backed session

Before wiring up any library, decide where the session actually lives. There are two real options, and the choice affects every other decision in this article.

A JWT stored in an httpOnly cookie is self-contained: the server can verify it without a database round trip, which is why it's the default for Auth.js (formerly NextAuth) and most DIY setups. The tradeoff is that revoking a single session before its expiry is awkward, since there's no server-side record to delete. You end up needing a denylist or a short expiry with silent refresh to make revocation practical at all.

A database-backed session (a session ID in the cookie, the actual record in Postgres or Redis) makes revocation trivial: delete the row, and the session is dead everywhere on its next check. The cost is a lookup on every authenticated request, which matters more at the edge, since middleware often can't reach your primary database without adding real latency.

If your app already issues JWTs from a NestJS backend, the pragmatic move is to let Next.js be a thin consumer of that token rather than reinventing session management on the frontend. Store the access token in an httpOnly cookie set by a Route Handler that proxies your login call, and let the backend remain the source of truth for issuing and revoking it. Reach for Auth.js when Next.js itself owns identity, credentials or OAuth providers, and doesn't have a separate backend issuing tokens.

Reading the session across Server Components, Client Components, and Route Handlers

Here's a session helper that works the same way regardless of where it's called from, which is the property that matters most:

// lib/auth/session.ts
import { cookies } from "next/headers";
import { cache } from "react";
import { verifyAccessToken } from "./jwt";

export interface SessionUser {
  id: string;
  email: string;
  orgId: string;
  role: string;
}

export const getSession = cache(async (): Promise<SessionUser | null> => {
  const token = (await cookies()).get("access_token")?.value;
  if (!token) return null;

  try {
    return await verifyAccessToken(token);
  } catch {
    return null;
  }
});

Wrapping it in React's cache matters more than it looks. Without it, calling getSession() from a layout and again from a page inside that layout means verifying the same JWT twice on the same request. With cache, the second call reuses the first result within that single render pass.

A Server Component reads it directly and passes only what the UI needs down to a Client Component, never the raw token:

// app/dashboard/page.tsx
import { getSession } from "@/lib/auth/session";
import { redirect } from "next/navigation";
import { AccountMenu } from "@/components/account-menu";

export default async function DashboardPage() {
  const session = await getSession();
  if (!session) redirect("/login");

  return (
    <div>
      <AccountMenu email={session.email} role={session.role} />
    </div>
  );
}

Route Handlers use the same helper, since they run in the same server request context:

// app/api/billing/route.ts
import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth/session";

export async function GET() {
  const session = await getSession();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const billing = await fetchBillingForOrg(session.orgId);
  return NextResponse.json(billing);
}

Client Components never read the cookie directly, since it's httpOnly by design. They receive whatever the parent Server Component decided to expose, or they call an authenticated Route Handler. If a Client Component needs to react to auth state changing without a full page reload, for example showing a "signed out in another tab" banner, don't reimplement a second, independent auth check in the browser. Use a small client-side session context, populated once from the server and kept fresh through a lightweight check.

Server Actions need their own authorization check, every time

Server Actions look like regular functions called from client code, which makes it tempting to assume the surrounding page already handled authorization. It didn't. A Server Action is a separate server entry point with its own request, and it must verify the session itself.

// app/dashboard/actions.ts
"use server";

import { getSession } from "@/lib/auth/session";
import { revalidatePath } from "next/cache";

export async function updateOrgName(formData: FormData) {
  const session = await getSession();
  if (!session) {
    throw new Error("Unauthorized");
  }
  if (session.role !== "admin") {
    throw new Error("Forbidden: admin role required");
  }

  const name = formData.get("name");
  if (typeof name !== "string" || name.trim().length === 0) {
    throw new Error("Invalid organization name");
  }

  await updateOrganization(session.orgId, name.trim());
  revalidatePath("/dashboard/settings");
}

The role check here isn't redundant with anything the UI already did to hide the "rename organization" button from non-admins. Hiding a button is a UX decision; it does nothing to stop a request crafted directly against the action's endpoint. Every Server Action that mutates state needs its own authorization check, written as if the UI restriction didn't exist, because from a security standpoint, it doesn't.

Talking to a separate backend without duplicating auth logic

If your API lives in a separate NestJS service rather than inside Next.js Route Handlers, don't re-verify the JWT independently in both places using different logic; that's how the two sides drift and one starts accepting tokens the other would reject. Let Next.js Route Handlers act as a thin proxy that forwards the same cookie-derived token as an Authorization header, and let the NestJS side own the actual verification.

// app/api/orders/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function GET() {
  const token = (await cookies()).get("access_token")?.value;
  if (!token) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const response = await fetch(`${process.env.API_BASE_URL}/orders`, {
    headers: { Authorization: `Bearer ${token}` },
    cache: "no-store",
  });

  if (!response.ok) {
    return NextResponse.json({ error: "Upstream error" }, { status: response.status });
  }

  return NextResponse.json(await response.json());
}

This keeps one system as the authority on whether a token is valid, and Next.js as a pass-through that never has to reimplement verification or revocation checks.

Production pitfalls

Reading the session in a Client Component through a raw fetch on every mount. This adds a network round trip before the UI can decide what to render, producing a visible flash between "checking" and "logged in." Resolve the session on the server, in the layout or page, and pass the result down as props.

Forgetting that middleware runs on the edge runtime. Middleware can't use Node-only APIs, including most JWT verification libraries and direct database clients. If your session check needs those, verify a lightweight signal in middleware (cookie presence, a cheap signature check) and do the full check in the Server Component or Route Handler, which is exactly the split the next article in this series covers.

Exposing the raw token to Client Components "just in case." Once a token is available to client-side JavaScript, it's reachable by any XSS payload that gets through. Keep it in an httpOnly cookie and never pass it as a prop, even to a component that only reads it for a header.

Skipping authorization checks inside Server Actions because the calling page already redirected unauthenticated users. The redirect only protects the page render. The action itself is a separate, directly callable entry point and needs its own check.

Key takeaways

  • Authentication in Next.js is mostly a distribution problem: the same session needs to be verified consistently across Server Components, Client Components, Route Handlers, and Server Actions, which don't share memory.
  • Choose between JWT cookies and database-backed sessions deliberately. JWTs skip the lookup but make revocation awkward; database sessions make revocation trivial at the cost of a lookup per request.
  • A single, cached session helper, callable from any server context, is worth building before reaching for a full auth library, and it's still the right abstraction if you do add one.
  • Server Actions are separate server entry points and need their own authorization check every time, regardless of what the surrounding page already verified.
  • Never pass a raw access token into a Client Component. Pass only the derived user data the UI actually needs to render.
  • If a separate backend issues and verifies tokens, let Next.js proxy the token rather than re-implementing verification logic on both sides.

Frequently asked questions

Should I use Auth.js (NextAuth) or roll my own session handling in Next.js?

Use Auth.js when Next.js itself owns identity: credentials or OAuth providers, with no separate backend issuing tokens. If a NestJS (or other) backend already handles login and revocation, a thin custom session helper that reads the cookie and forwards the token is usually less work and avoids maintaining two sources of truth for one session.

Can middleware fully protect a route, or do I still need checks in the page?

Middleware is a good first filter for redirecting unauthenticated requests early, but it runs on the edge runtime with limited APIs, so it typically does a lightweight check rather than full verification. Treat it as a fast-fail layer, not the only check; the Server Component or Route Handler behind it should still verify the session properly.

Why does my session appear correct in a Server Component but stale in a Client Component after logging out?

The Client Component likely received the session as a one-time prop or read it from a context that isn't revalidated. Logging out needs to clear the cookie and trigger a full navigation or explicit refresh of the client-side session state, otherwise the client keeps rendering data captured before logout.

Is it safe to store a JWT in localStorage instead of a cookie for a Next.js app?

Not for anything that matters. `localStorage` is readable by any JavaScript running on the page, which means any successful XSS payload can exfiltrate the token directly. An `httpOnly` cookie is invisible to JavaScript entirely, which removes that specific attack path even if an XSS vulnerability exists elsewhere.

Do Server Actions need CSRF protection separately from the session cookie?

Next.js includes built-in origin checks for Server Actions that reject requests from an unexpected origin, covering the core CSRF risk for actions invoked through the framework's own mechanism. Combine this with `sameSite` cookie settings, and remember it doesn't cover custom Route Handlers, which need their own CSRF consideration if they accept state-changing browser requests.

Related articles

The Next.js App Router Explained — Aman Kumar Singh
Server Actions for Mutations — Aman Kumar Singh
A React and Next.js Production Checklist — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.