Next.js 16 Authentication with WorkOS AuthKit: A Practical Guide
A practical guide to wiring WorkOS AuthKit into a Next.js 16 App Router app: middleware, async cookies, protecting Server Components, and a security checklist.
On this page
Authentication is where most Next.js projects get ugly. It starts clean — one login form, one session cookie — and three sprints later you've got a tangle of client-side token juggling, race conditions on refresh, and a useEffect somewhere quietly leaking a redirect loop. I've shipped enough of these to have opinions, and after building auth on the App Router more times than I'd like to admit, AuthKit from WorkOS plus the Next.js 16 App Router is the cleanest path I've found. It keeps the session on the server where it belongs, it understands the new async request APIs, and it doesn't fight the framework.
This is the setup I actually use in production. No filler, real tradeoffs.
Why AuthKit and not roll-your-own
I'm not religious about auth libraries, but I am religious about not storing JWTs in localStorage and not writing my own session refresh logic. AuthKit gives you a hosted sign-in flow (or your own UI via the API), encrypted session cookies, and — the part that matters on Next.js 16 — first-class helpers that read the session from the request context without you touching cookies directly. You get SSO, magic links, and social login behind one integration, and you can defer the "do we need enterprise SSO" decision instead of rebuilding auth when a customer asks for SAML.
The tradeoff: you're tied to WorkOS as an identity provider, and the free tier has a user cap. For most products that's a fine deal. If you're building something where the IdP must be self-hosted, look elsewhere.
Installing and configuring
Install the package:
npm install @workos-inc/authkit-nextjsThen the environment variables. Grab the API key and client ID from the WorkOS dashboard, and generate a cookie password that is at least 32 characters — this is the key that encrypts your session cookie, so treat it like a secret, not a config value.
# .env.local
WORKOS_API_KEY=sk_live_...
WORKOS_CLIENT_ID=client_...
WORKOS_COOKIE_PASSWORD=$(openssl rand -base64 32)
NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callbackIn the WorkOS dashboard, register that exact redirect URI under your environment's Redirects settings. A mismatch here is the single most common reason a first integration silently fails — the callback comes back, WorkOS rejects the redirect, and you get a cryptic error instead of a session.
Wrapping the app
The provider goes in your root layout. It doesn't fetch anything heavy on the server — it sets up the context that the client helpers (useAuth, sign-in buttons) rely on.
// app/layout.tsx
import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthKitProvider>{children}</AuthKitProvider>
</body>
</html>
);
}The middleware (and why it runs on Node now)
AuthKit uses middleware to keep the session cookie fresh on every request — it transparently handles refresh so your Server Components always read a valid session. Here's the whole file:
// middleware.ts
import { authkitMiddleware } from "@workos-inc/authkit-nextjs";
export default authkitMiddleware();
export const config = {
matcher: [
// Run on everything except static assets and the Next internals
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|svg)$).*)",
],
};A word on runtime, because this changed and people get it wrong. In 2026, Edge middleware is discouraged. Next.js 16 runs middleware on the Node.js runtime by default — on Vercel that's via Fluid Compute — and the older naming is shifting toward calling this layer the "proxy." The practical upshot is good news: AuthKit's middleware needs Node APIs for its crypto, and you no longer have to fight the Edge runtime's limitations or worry about which Node built-ins are polyfilled. Don't add export const runtime = "edge" to this file. Let it run on Node.
If you want some routes to require auth at the middleware level (redirect to sign-in before the page even renders), pass middlewareAuth:
export default authkitMiddleware({
middlewareAuth: {
enabled: true,
unauthenticatedPaths: ["/", "/pricing", "/login"],
},
});Reading the user in a Server Component
This is where Next.js 16's async request APIs matter. cookies() and headers() are async now — you await them. AuthKit's withAuth() wraps that for you, so you don't touch cookies directly; you just await the session.
// app/dashboard/page.tsx
import { withAuth } from "@workos-inc/authkit-nextjs";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const { user } = await withAuth();
if (!user) {
redirect("/login");
}
return (
<main>
<h1>Welcome back, {user.firstName ?? user.email}</h1>
<p>Your user ID is {user.id}</p>
</main>
);
}If a route should always require auth and you'd rather throw than branch, use withAuth({ ensureSignedIn: true }) — it redirects to sign-in automatically when there's no session. I reach for the explicit if (!user) check when I want to render a different state, and ensureSignedIn for hard-gated pages.
One gotcha worth calling out: because withAuth() reads from the request, it pulls the route out of static prerendering. That's correct behavior — an authenticated page can't be cached at build time — but if you accidentally call it in a shared layout, you'll deopt every page under that layout into dynamic rendering. Keep auth reads as close to the protected leaf as you can.
Sign-in and the callback route
You need a callback route to complete the OAuth handshake. AuthKit ships a handler — wire it to the path you registered as your redirect URI:
// app/callback/route.ts
import { handleAuth } from "@workos-inc/authkit-nextjs";
export const GET = handleAuth({
returnPathname: "/dashboard",
});For the sign-in button itself, generate the authorization URL on the server and link to it. This is a Server Component, so the URL is built per request:
// app/login/page.tsx
import { getSignInUrl } from "@workos-inc/authkit-nextjs";
export default async function LoginPage() {
const signInUrl = await getSignInUrl();
return (
<main>
<h1>Sign in</h1>
<a href={signInUrl}>Continue with WorkOS</a>
</main>
);
}Sign-out is a Server Action calling signOut(), which clears the session cookie and redirects:
// app/components/sign-out.tsx
import { signOut } from "@workos-inc/authkit-nextjs";
export function SignOutButton() {
return (
<form
action={async () => {
"use server";
await signOut();
}}
>
<button type="submit">Sign out</button>
</form>
);
}Protecting a Route Handler
API routes need the same treatment. withAuth() works identically inside a Route Handler — same async session read, no manual cookie parsing:
// app/api/projects/route.ts
import { withAuth } from "@workos-inc/authkit-nextjs";
import { NextResponse } from "next/server";
export async function GET() {
const { user } = await withAuth();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const projects = await getProjectsForUser(user.id);
return NextResponse.json({ projects });
}Note I return a real 401 here rather than redirecting — Route Handlers are consumed by clients, not browsers following redirects, so respond with a status code the caller can handle.
Next.js 16 gotchas, condensed
A few things that bit me or my teammates:
- Async cookies and headers. Every
cookies()/headers()call isawait-ed now. If you see "used outside a request scope," you've called a session helper somewhere static — like top-level module code or a cached function. - The caching/proxy model. Calling
withAuth()marks the render dynamic. Don't wrap it inunstable_cacheor a"use cache"boundary — caching an authenticated response across users is a security bug, not an optimization. - Server Components are the default. Read the user on the server and pass primitives down. Don't hydrate the full user object into client state unless a client component genuinely needs it.
- Don't go Edge. As above — keep the middleware on Node.
Security checklist
Before you ship, walk this:
WORKOS_COOKIE_PASSWORDis 32+ chars, unique per environment, and stored as a secret — never committed.- Redirect URIs are registered exactly in the dashboard, with the production URI separate from local.
- Every protected Server Component and Route Handler reads
withAuth()and handles thenulluser case. - Route Handlers return
401/403rather than leaking data on missing auth. - No authenticated response is wrapped in a cache boundary.
- Session cookies are HTTP-only and
Securein production (AuthKit's default — verify you haven't overridden it). - Sign-out actually clears the session server-side, not just client state.
Get those right and auth stops being the ugly part of the project. It becomes the boring part — which, for authentication, is exactly what you want.