Setting up Supabase Auth in Next.js App Router takes about 30 minutes if you understand what you're building. Most tutorials skip the "why" — so you copy the code, it half-works, and you spend hours debugging random logouts and broken OAuth redirects.
This guide covers the concepts and the key code. Once you understand the moving parts, the rest clicks into place.
The short version
4 files, 30 minutes, full auth
- Set up a browser client (
createBrowserClient) for Client Components - Set up a server client (
createServerClient) for Server Components, Actions, and Route Handlers - Add middleware to refresh sessions on every request using
getClaims() - Add a
/auth/callbackroute to exchange OAuth and magic-link codes for sessions
Assumes familiarity with Next.js 15 App Router and basic Supabase setup. Works with @supabase/ssr 0.5+.
Why App Router makes auth harder
In a regular React app, auth is simple: store the session in memory or localStorage, read it anywhere. App Router breaks that model. Server Components can't access browser state. Middleware runs in an edge runtime before any component renders.
Supabase stores sessions in cookies — that's the right call for SSR. But you need to read and write those cookies in three different contexts:
- Browser — Client Components, sign-in forms, OAuth buttons
- Server — Server Components, Server Actions, Route Handlers
- Edge — Middleware, runs before the request hits your app
These three contexts aren't just different environments — they each have a fundamentally different relationship with cookies. In the browser, cookies are read and written through the Web APIs your component already has access to. On the server, Next.js exposes cookies through its cookies() helper from next/headers, but that API is read-only in Server Components and writable only in Server Actions and Route Handlers. In middleware, you're operating at the edge before any rendering happens, so you read cookies directly from the incoming NextRequest and write them onto the outgoing Response — a completely different surface area.
That's why @supabase/ssr gives you two separate client factories. Same Supabase API, different cookie access patterns underneath.
The env variable that trips everyone up
Supabase is migrating from ANON_KEY to PUBLISHABLE_KEY. New projects get publishable keys by default. Most examples online still use the old name. It silently fails on new projects.
The following .env.local file shows the two variables your app needs. The URL identifies your Supabase project, and the publishable key is the safe-to-expose client credential — it's meant to be prefixed with NEXT_PUBLIC_ because it ships to the browser.
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxFind the correct value in your Supabase project under Connect → API Keys. If you see an anon key instead of a publishable key, your project was created before the migration — you can use ANON_KEY for now, but rename the variable to match these examples to keep things consistent.
The 4 pieces you need
1. Browser client
createBrowserClient is specifically designed for Client Components — the ones that run in the browser after hydration. You would not use this in a Server Component, a Server Action, or middleware, because those contexts don't have access to document.cookie the way a browser client does. The function is safe to call multiple times in the same session because @supabase/ssr manages a singleton internally, so repeated calls from different components won't create duplicate connections or cause state divergence.
Use this wherever auth happens on the client side: sign-in forms, OAuth buttons, sign-out handlers, and onAuthStateChange listeners that react to session changes in real time.
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}The function returns a new client object on each call, but Supabase deduplicates the underlying connection internally — calling createClient() in two different components won't create two separate connections. Notice that both environment variables are prefixed with NEXT_PUBLIC_ and use the publishable key, not a service role key — this code runs in the browser and must never hold privileged credentials.
2. Server client
The server client is used in Server Components, Server Actions, and Route Handlers — anywhere that runs on the server and needs to read the authenticated user's session from cookies. In Next.js 15, the cookies() function from next/headers became async, which means any function that calls it must also be async. That's why createClient here returns a Promise — you need to await the cookie store before you can pass it to createServerClient.
The try/catch around setAll is the part that confuses most developers. When @supabase/ssr refreshes a token, it needs to write updated cookies back to the response. In Server Components, Next.js does not allow cookie writes — the response has already been started. Rather than crashing your component with an unhandled error, the try/catch absorbs that write failure gracefully. Token refresh writes are handled by middleware on every incoming request anyway, so the Server Component's inability to write cookies is never a real problem in practice.
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (toSet) => {
try {
toSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Safe to ignore — middleware handles token refresh writes
}
},
},
}
);
}The setAll try/catch specifically catches the "cookies can only be modified in a Server Action or Route Handler" error that Next.js throws from inside Server Components. It doesn't swallow real auth errors — those surface through the normal { data, error } return values of each Supabase call. Because middleware runs before Server Components and has already refreshed the token, the server client only needs to read cookies reliably, not write them.
3. Middleware for session refresh
This is the most critical piece — and the most underexplained.
JWTs expire. Without middleware, a user idle for an hour gets silently logged out on their next server-side request. Middleware runs on every request before any component renders. That makes it the right place to validate and refresh the token.
The reason token refresh belongs in middleware — and not in a Server Component or layout — is timing. Middleware executes before Next.js begins rendering, which means it can attach fresh cookies to the response before any Server Component reads the session. If you tried to refresh the token inside a layout or page, the rendering would already be underway by the time the new token arrived, and subsequent Server Components in the same render tree might still see the old session.
Use getClaims() in middleware, not getUser() or getSession(). getSession() doesn't validate the JWT — it returns whatever is in the cookie, tampered or not. getUser() makes a network request on every request — too slow for middleware. getClaims() validates locally against Supabase's public keys: fast and secure.
One more rule: always return the original response object. Supabase attaches refreshed cookies to the response it builds internally. Return a new NextResponse.next() and those cookies get dropped — the browser keeps sending the expired token.
The following middleware file creates a Supabase client that reads cookies from the incoming request and writes them onto the outgoing response, then calls getClaims() to validate the JWT locally. It also demonstrates a simple route protection pattern for the /dashboard path.
// src/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (toSet) => {
toSet.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
toSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
// Must be called immediately after createServerClient — nothing in between
const { data } = await supabase.auth.getClaims();
if (!data?.claims && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Always return the original — a new NextResponse.next() drops the refreshed cookies
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
};The matcher pattern excludes static assets — _next/static, _next/image, favicons, and image files — so middleware doesn't run on every font or image fetch, which would add unnecessary latency to purely static responses. The redirect logic checks for the absence of valid claims before protecting /dashboard, meaning any path that doesn't start with /dashboard passes through even for unauthenticated users — you'd extend this check to protect additional routes. Finally, the function always returns the local response variable, never a freshly constructed NextResponse.next() — this is the variable that Supabase wrote the refreshed cookies onto inside setAll, and discarding it in favor of a new response would silently drop those cookie updates.
4. Callback route for OAuth and magic links
When a user authenticates via OAuth (GitHub, Google, etc.) or clicks a magic link in their email, the authentication provider does not give your app a session directly. Instead, it redirects the user back to your app with a short-lived code query parameter in the URL — this is the PKCE (Proof Key for Code Exchange) flow. That code is a one-time token that your server must exchange for a real session by calling exchangeCodeForSession(). The code typically expires within a few minutes, so this exchange needs to happen immediately when the user lands on your callback URL. Without this route, the code arrives in the browser's URL bar, nothing in your app reads it, and the user ends up on your page with no active session — no error, no warning, just silently unauthenticated.
The following route handler lives at /auth/callback and is responsible for completing that exchange. It reads the code from the URL, calls exchangeCodeForSession() using the server Supabase client, and then redirects the user to their destination.
// src/app/auth/callback/route.ts
import { createClient } from "@/lib/supabase/server";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = searchParams.get("next") ?? "/dashboard";
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) return NextResponse.redirect(`${origin}${next}`);
}
return NextResponse.redirect(`${origin}/login?error=auth_callback`);
}The next parameter is a forwarding mechanism: you pass it when initiating the OAuth flow, and after a successful exchange the user lands wherever you specified instead of always going to /dashboard. If the exchange fails — because the code was already used, expired, or missing entirely — the route redirects to /login?error=auth_callback, giving your login page a signal to display an appropriate error message to the user.
Pass next from your OAuth call to control where users land after sign-in:
await supabase.auth.signInWithOAuth({
provider: "github",
options: {
redirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
},
});Reading the user in Server Components
In Server Components, you should always call getUser() to retrieve the authenticated user — not getSession() and not getClaims(). The distinction matters for security: getSession() reads the raw JWT from the cookie and returns it without any server-side validation, which means a tampered or replayed token would appear valid. getClaims() does validate the JWT signature locally, but it's designed for the high-frequency middleware path where a network round-trip on every request would be too costly. getUser() makes a network call to Supabase's auth server to validate the session and return the current user object — it's the authoritative check, appropriate for pages that need to render user-specific data.
In the example below, the Server Component awaits the Supabase client, calls getUser(), and redirects unauthenticated visitors before any rendering happens. This is the standard pattern for protected pages in Next.js App Router.
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
return <div>Hello, {user.email}</div>;
}The redirect("/login") call inside a Server Component triggers a Next.js navigation response before anything is rendered to the client — there is no flash of the protected page for unauthenticated users. This check exists in the Server Component in addition to the middleware redirect because middleware uses getClaims() for speed, and having an authoritative getUser() check at the page level ensures that even edge cases — like a cookie that passed local JWT validation but was revoked server-side — are caught before the user sees any protected data.
Your complete setup at a glance
| File | What it does |
|---|---|
src/lib/supabase/client.ts | Browser client for Client Components |
src/lib/supabase/server.ts | Server client for Server Components, Actions, Route Handlers |
src/middleware.ts | Session refresh + route protection on every request |
src/app/auth/callback/route.ts | PKCE code exchange for OAuth and magic links |
Common mistakes
What the following mistakes have in common is that they all produce silent failures — no thrown exception, no console error, just auth that doesn't work the way you expect and takes hours to trace back to its source.
Missing the callback route. OAuth and magic links silently fail without /auth/callback. Add it even if you don't use OAuth today — magic links need it too.
getSession()server-side — returns the raw cookie without JWT validation. UsegetClaims()in middleware,getUser()everywhere else on the server.- Returning a new response from middleware — drops the refreshed cookies. Always return the original
response. If you must redirect, copy cookies over:redirectResponse.cookies.setAll(response.cookies.getAll()). - Wrong env variable name —
ANON_KEYsilently fails on new Supabase projects. UsePUBLISHABLE_KEY.
Frequently asked questions
What's the difference between getSession() and getUser() in Supabase?
getSession() returns the raw JWT from the cookie without validating it — never use it server-side. getUser() validates the session against Supabase's auth server. In middleware, use getClaims() (fast, local validation). Everywhere else on the server, use getUser().
Why do I need middleware for Supabase Auth in Next.js?
JWTs expire (typically every hour). Middleware runs before every request and refreshes the token when needed. Without it, server-side session reads fail after the token expires, causing unexpected logouts even for active users.
What is the PKCE callback route for?
OAuth providers and magic links redirect users back to your app with a one-time code in the URL. Your /auth/callback route exchanges that code for a real session with exchangeCodeForSession(). Skip this route and OAuth silently fails — the code arrives, nothing exchanges it, and the user lands without a session.
Should I use ANON_KEY or PUBLISHABLE_KEY in new Supabase projects?
Use PUBLISHABLE_KEY. New Supabase projects only generate publishable keys. The old ANON_KEY name still works on legacy projects but silently fails on new ones — and most examples online haven't been updated yet.
Want to build the fast way?
The login-02 block ships with everything pre-wired: both Supabase clients, the middleware, the callback route, email/password sign-in, Google and GitHub OAuth, inline error states, and a shadcn/ui login UI. Set your env variables and your auth is live.


