Migrating Off @supabase/auth-helpers-nextjs: Five Factories Become Two
Five client factories map onto two: createBrowserClient and createServerClient. The cookie adapter and getSession() are what find-and-replace misses.
@supabase/auth-helpers-nextjs is deprecated. Its replacement is @supabase/ssr, which collapses the old package's five framework-specific factories into two: createBrowserClient and createServerClient. The migration is mostly mechanical, with two places it is not — the cookie adapter changed shape, and the session call you were probably using is the wrong one.
This storefront runs the post-migration setup: @supabase/ssr at ^0.12.0, three client factories totalling 55 lines, and session refresh in Next.js 16's proxy. Here is each old call mapped onto what actually ships here.
The mapping, factory by factory
@supabase/auth-helpers-nextjs | @supabase/ssr | Where it lives here |
|---|---|---|
createClientComponentClient() | createBrowserClient(url, key) | src/lib/supabase/client.ts |
createServerComponentClient({ cookies }) | createServerClient(url, key, { cookies }) | src/lib/supabase/server.ts |
createRouteHandlerClient({ cookies }) | createServerClient(url, key, { cookies }) | same file — one factory serves both |
createServerActionClient({ cookies }) | createServerClient(url, key, { cookies }) | same file again |
createMiddlewareClient({ req, res }) | createServerClient(url, key, { cookies }) reading the request | src/proxy.ts |
createPagesBrowserClient() / createPagesServerClient() | createBrowserClient / createServerClient | n/a — App Router only here |
The consolidation is the headline: four of those five rows become the same function. What distinguishes a Server Component client from a Route Handler client is no longer the import — it is what you hand the cookies adapter and whether writes are allowed where you are calling from.
The browser client is nine lines
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
/** Browser Supabase client (anon key). Use in Client Components. */
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}
Two differences from createClientComponentClient(). The URL and key are explicit rather than read from the environment by the library, which is a small annoyance and a real improvement — the values are visible at the call site and type-checked. And there is no singleton behaviour to reason about; call it where you need it.
The server client is where the cookie API changed
This is the part a find-and-replace will not do for you. The auth-helpers package took a cookies function and used Next's own store. @supabase/ssr takes an adapter, and in current versions that adapter is getAll/setAll — not the older get/set/remove triple, which is deprecated and will warn.
// src/lib/supabase/server.ts
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
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: (cookiesToSet) => {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Called from a Server Component render (cookies are read-only there);
// the proxy refreshes the session cookie instead.
}
},
},
},
);
}
Three things in there are load-bearing.
cookies() is awaited. In Next.js 15 and 16 it returns a promise, which is why this factory is async and every call site does await createClient(). Code copied from an auth-helpers tutorial will pass the function itself and get a store that does not behave.
The try/catch around setAll is not defensive padding. Cookies are read-only during a Server Component render, so set throws there — and it must be allowed to, silently, because the same factory is also used from Route Handlers and Server Actions where writing is legal and necessary. Swallowing it here is correct precisely because something else takes responsibility for refresh, which is the next section.
And the anon key is the key. This client enforces RLS. The service-role client is a separate file with import "server-only" at the top, and it exists for exactly two jobs — webhook writes and signed-URL issuance — which the service-role post covers.
The middleware client is now a file with a different name
createMiddlewareClient({ req, res }) was the awkward one in the old package, because middleware has to write rotated cookies onto a response it also has to return. @supabase/ssr keeps that shape, and in Next.js 16 the file itself was renamed from middleware.ts to proxy.ts:
// src/proxy.ts
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
await supabase.auth.getClaims();
return response;
}
The double write — onto the request, then rebuild the response, then onto the response — looks redundant and is not. The request copy is what the rest of this request sees; the response copy is what the browser keeps. Drop either and you get a session that works on one request and not the next. The rename is covered separately in the Next.js 16 proxy post; for this migration the only consequence is which filename the code goes in.
The call that should change while you are in there
The old package's examples lean on getSession(). If you are migrating anyway, this is the moment to stop using it for anything that decides access:
await supabase.auth.getClaims(); // validates the JWT
await supabase.auth.getSession(); // returns whatever cookie is present
getSession() reads the session from the cookie without verifying it. On the server that means a value an attacker controls, and the cookie is not HttpOnly — @supabase/ssr deliberately sets httpOnly: false so that createBrowserClient can read it from document.cookie. getClaims() validates the JWT, and getUser() round-trips to the auth server; this codebase uses getClaims() for refresh and getUser() where email_confirmed_at is needed, and getSession() nowhere at all. It is a standing rule in the repo's own CLAUDE.md.
The bundle cost the migration does not remove
Both packages ship the same auth-js core to the browser, so nothing about switching makes the client smaller. On this site @supabase/ssr plus auth-js measured ~68 KiB over the wire, 255 KiB parsed — and it was reaching pages with no account UI on them, because two module-scope imports pulled it in: Header, which renders site-wide, and useOwnedProducts, which renders behind every templates grid.
The fix is a deferred import plus a cookie probe:
// src/lib/supabase/lazyClient.ts
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}
export async function loadSupabaseClient(): Promise<BrowserClient> {
const { createClient } = await import("@/lib/supabase/client");
return createClient();
}
Signed-out visitors never download the auth stack, because the regex already answered the question the stack would have answered. It works because of the same httpOnly: false decision noted above — the probe sees exactly what the client would. It is a rendering shortcut only: every real gate stays server-side. The full auth post has the three-client picture this sits inside.
Migration order that avoids a half-broken app
- Install
@supabase/ssr, leave@supabase/auth-helpers-nextjsinstalled for now. - Write the two new factories (
client.ts,server.ts) beside the old imports. - Convert the middleware/proxy file first — it is the one that keeps sessions valid, so a broken one makes every other symptom confusing.
- Convert Server Components, then Route Handlers, then Server Actions. Each is the same factory.
- Replace every
getSession()used for an access decision withgetClaims()orgetUser(). - Remove
@supabase/auth-helpers-nextjsand grep for it — a stray import keeps a second cookie implementation alive alongside the new one.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
cookies() should be awaited | cookies passed as a function, auth-helpers style | const cookieStore = await cookies() and pass its methods |
Deprecation warning about get/set/remove | Adapter written to the older @supabase/ssr shape | Use getAll/setAll |
| "Cookies can only be modified in a Server Action or Route Handler" | setAll throwing during a Server Component render | Swallow it there and let the proxy refresh, as above |
| Signed in, then signed out again on the next navigation | Middleware writes cookies to the response but not the request | Write both, and rebuild the response after the request write |
| Two auth implementations disagree | Both packages still installed and imported somewhere | Uninstall the old one and grep for leftover imports |
| Session looks valid but is stale or forged | getSession() used for an authorization decision | getClaims() or getUser() |
| Auth SDK loads on pages with no account UI | Module-scope import of the browser client | Dynamic import inside the effect that needs it |
Frequently asked questions
Is @supabase/auth-helpers-nextjs still safe to use?
It still installs and runs, but it is in maintenance mode and the upstream repository is marked deprecated. It will not gain fixes for new Next.js behaviour, which matters because the two most recent breaking changes for this code — async cookies() and the middleware.ts → proxy.ts rename — are both Next.js-side.
Do I have to migrate all at once? No. Both packages can be installed together while you convert file by file, and this is the recommended order because the middleware conversion is the one that changes behaviour globally. Finish by uninstalling the old package so a stray import cannot resurrect it.
Which @supabase/ssr factory replaces createRouteHandlerClient?
createServerClient — the same one that replaces createServerComponentClient and createServerActionClient. The old package's four server factories exist as one function now; the difference is only where you call it from.
Does migrating shrink the client bundle?
No. Both ship the same auth-js core — ~68 KiB over the wire as measured on this site. Deferring the import so signed-out visitors never fetch it is a separate change, and the one that actually moves the number.
Why not keep getSession() since it is faster?
Because it does not verify anything. It returns whatever is in a cookie that is deliberately not HttpOnly. For rendering "you appear signed in" that is fine; for deciding what someone may read or download it is the whole vulnerability.
Templates in this post
ASoc Fade is a traditional-barbershop site with services, a price list, team profiles, booking and a grooming shop. ASoc Fiscal is a financial-platform marketing site covering payments, invoicing and a tabbed feature showcase. ASoc Flow is a workflow-automation SaaS landing page with a hub preview and a visual flow builder. Each is the public half of a product whose signed-in half would sit behind exactly the two factories above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
