Next.js App Router + Supabase Auth: The SSR Setup That Works

Two clients, a proxy that refreshes the session, getClaims or getUser instead of getSession, and a layout gate — the Supabase SSR auth setup for the Next.js App Router, with the mistakes that cause random logouts and leaks.

AAAayush AdhikariAugust 14, 2026 7 min read

Next.js App Router Supabase auth works reliably with four pieces: a browser client and a cookie-based server client from @supabase/ssr, a proxy (proxy.ts in Next.js 16, middleware.ts before) that refreshes the session on every request, and server-side checks that call getClaims() or getUser() — never getSession() — before trusting who the user is. Gate protected routes in a server layout, keep the service-role key off the client, and let row-level security make the final decision.

Why App Router auth is confusing

In the App Router, code runs in several places: Server Components (which can read cookies but not set them), Server Actions and Route Handlers (which can read and set them), the proxy (which runs before rendering), and Client Components in the browser. Each needs the session, and each accesses cookies differently. Most auth bugs — random logouts, users seeing stale data, "works locally, fails in production" — come from treating these as one environment.

Piece 1: two clients

The browser client runs in Client Components and uses only the public (anon/publishable) key:

import { createBrowserClient } from "@supabase/ssr";
 
export const createClient = () =>
  createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);

The server client runs in Server Components, Server Actions and Route Handlers and reads the request's cookies. In recent Next.js versions cookies() is async:

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_ANON_KEY!, {
    cookies: {
      getAll: () => cookieStore.getAll(),
      setAll: (list) => {
        try {
          list.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
        } catch {
          // Server Components can't set cookies; the proxy refreshes the session instead.
        }
      },
    },
  });
}

Create a new server client per request. A module-level singleton would share one user's session with the next request.

Piece 2: the proxy refreshes the session

Access tokens expire. Something must exchange the refresh token for a new access token and write the new cookies — before Server Components render, because they can't write cookies themselves. That's the proxy's job.

Supabase's Next.js guide describes its responsibilities: refresh the auth token, pass the refreshed token to Server Components via the request cookies, and pass it to the browser via the response cookies. In Next.js 16 the file is proxy.ts; in Next.js 15 and earlier it's middleware.ts.

Two rules from experience:

  • Run nothing between creating the client and the auth call. Code in between can make the session refresh unreliable, and users get logged out at random.
  • Return the response object the Supabase client wrote cookies to. Creating a fresh NextResponse drops the refreshed cookies.

The proxy is also a convenient place to redirect signed-out users away from app routes. Keep a short allowlist of public paths (/login, /blog, /sitemap.xml, /robots.txt …) and redirect everything else. Remember to add new public files to that list — in LetRelay, a new /ads.txt route would otherwise have redirected search and ad crawlers to the login page.

Piece 3: verify, don't just read

This is the mistake with real security consequences. Supabase's documentation is explicit: never trust supabase.auth.getSession() inside server code, because it reads the session out of the cookie without revalidating it. A cookie can be forged or stale.

  • getClaims() verifies the token's signature (locally, when your project uses asymmetric signing keys) and returns its claims — fast, suitable for the proxy and most server checks.
  • getUser() asks the Supabase Auth server for the user — authoritative, one network round trip.
  • getSession() is fine in the browser for UI state, never for authorization on the server.

Piece 4: gate in the layout

Put the check once, in the layout that wraps every protected page:

// app/(dashboard)/layout.tsx
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");
 
  const { data: profile } = await supabase.from("profiles").select("role, status").eq("id", user.id).single();
  if (!profile || profile.status !== "approved") redirect("/pending");
 
  return <Shell role={profile.role}>{children}</Shell>;
}

Every page under the layout can now assume a signed-in, approved user. The layout decides what to show; the database still decides what can be read, through row-level security. A page that forgets a check is still protected by the policies behind its queries.

Keys: which one goes where

Key Where Bypasses RLS?
Anon / publishable Browser and server (NEXT_PUBLIC_… is fine) No
Service role / secret Server only, never NEXT_PUBLIC_… Yes

Use the service-role key only for writes that must cross a user's permissions: creating an organization at sign-up, background jobs, webhook receivers. Anything a user triggers should run on their own session so RLS applies. The multi-tenant side of this is in the Supabase multi-tenant RLS guide.

Public pages need a different client

Not every page is personal. A blog, a pricing page or a public FAQ should be statically generated and cached, and anything that reads cookies makes a page dynamic. So public pages shouldn't use the cookie-based server client at all. Use a plain Supabase client with the anon key and no cookie access:

import { createClient } from "@supabase/supabase-js";
 
const publicClient = () =>
  createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {
    auth: { persistSession: false },
  });

It queries as the anon role, so row-level security decides what's public — for LetRelay's blog, only posts whose status is published and whose publish time has arrived. Because it never touches cookies, pages using it can be pre-rendered at build time and revalidated on a schedule (LetRelay's blog revalidates hourly). One client per purpose: personal pages get the cookie client, public pages get the anonymous one.

Auth callbacks and password resets

Email confirmation, magic links and password-reset emails send the user back with a code that must be exchanged for a session. Handle that in a Route Handler (e.g. /auth/callback) that calls exchangeCodeForSession and then redirects. Configure the allowed redirect URLs in Supabase to include your production domain; otherwise emails link to localhost or fail. And add per-IP rate limits to login, sign-up and reset forms — see rate limiting without Redis.

Common mistakes

  • Using getSession() on the server for authorization. It doesn't verify the token.
  • Forgetting the proxy, or returning a different response object from it — random logouts follow.
  • A shared server client across requests — one user's session leaks into another's render.
  • Caching pages that depend on the user. Personalized pages must be dynamic; public pages (a blog) should use a cookie-less client so they can be statically generated.
  • Service-role key in a NEXT_PUBLIC_ variable. It ships to every browser and bypasses every policy.
  • Trusting user_metadata for roles. Users can edit it. Store roles in a table they can't write, or in app_metadata.

A checklist

  • @supabase/ssr browser client and per-request server client
  • proxy.ts refreshes the session and returns the cookie-carrying response
  • Public paths allowlisted; everything else redirects when signed out
  • Server checks use getClaims() / getUser()
  • Protected layout loads profile and redirects on missing or unapproved accounts
  • RLS enabled on every table
  • Service-role key server-only; used in few, reviewed places
  • Callback route for email links; redirect URLs configured
  • Rate limits on auth forms

FAQ

What's the difference between getClaims, getUser and getSession?

getClaims() verifies the access token and returns its claims; getUser() fetches the user from the Auth server; getSession() returns whatever session is in storage without verifying it. Use the first two for server-side authorization, the third only for client UI.

Why do users get logged out randomly in Next.js with Supabase?

Usually the proxy isn't refreshing the session, or it returns a response without the refreshed cookies, or code runs between creating the client and the auth call. Follow the official proxy pattern exactly.

Is middleware renamed to proxy in Next.js 16?

Yes — the file is proxy.ts in Next.js 16; middleware.ts is the older name. The Supabase session-refresh logic is the same.

Can Server Components set auth cookies?

No. They can read cookies but not set them, which is why the proxy performs the refresh before rendering.

Sources

AA
Aayush Adhikari

Building Relay — the internal request desk with AI triage and SLA tracking.

Run your internal requests on LetRelay

AI triage, SLA-tracked queues, and bottleneck analytics — the help desk your team actually likes. Free to start.

Try LetRelay free No credit card required
Ad spaceYour Google AdSense unit shows here once approved.

Keep reading