The answer. createClient from supabase-js keeps its session in memory or localStorage, which a server never has, so a server-rendered page using it thinks nobody is logged in even while the browser is signed in. @supabase/ssr fixes the transport: createBrowserClient (a singleton) stores the session in cookies, and createServerClient reads them through a cookies: { getAll, setAll } adapter you supply from the framework. Build the server client inside the request (a route handler, a server component, a loader), never at module scope, because it carries that user's cookies. Access tokens expire after about an hour and only the browser client refreshes automatically, so a middleware or proxy layer must call the client on every request to refresh and write the new cookies back onto both the outgoing response and the request that downstream code will read.
The pattern.
// middleware.ts (Next.js) — refresh and propagate the session
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(URL, PUBLISHABLE_KEY, {
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookies) => {
cookies.forEach(({ name, value }) => request.cookies.set(name, value)); // the request is a copy
response = NextResponse.next({ request });
cookies.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
},
},
});
const { data } = await supabase.auth.getClaims(); // verifies + refreshes when needed
if (!data?.claims && request.nextUrl.pathname.startsWith("/app")) return NextResponse.redirect(new URL("/login", request.url));
return response;
}
export const config = { matcher: ["/((?!.*\\.).*)"] }; // skip files: favicon, images
Watch out.
- A rewrite in middleware creates a fresh response; copy the refreshed cookies onto it or the browser keeps the old session.
setAllreceives cache headers for a reason: session responses must not be cached by a CDN.- Never hand cookies to the admin client. It is a plain
createClientwith the secret key and no session at all.
Related: server-side-trust-getclaims-not-getsession · publishable-and-secret-keys-replace-anon-and-service-role · magic-links-need-an-allow-listed-redirect-and-a-callback