Supabase JavaScript: One Package, Three Clients, and the 68 KiB Every Page Was Paying
supabase-js, @supabase/ssr and the admin client, read from a real Next.js 16 storefront: 18 queries, getUser vs getClaims, and the lazy import that removed 68 KiB.
In JavaScript, Supabase is one package, @supabase/supabase-js, that wraps Postgres, Auth and Storage behind a single createClient() call. In a Next.js app you rarely call it directly: @supabase/ssr wraps it to keep the session in cookies, giving you a browser client, a server client, and — for the cases that must bypass RLS — a separate admin client built on the raw package.
This storefront runs all three. package.json pins @supabase/supabase-js@^2.110.0 and @supabase/ssr@^0.12.0, the code makes 18 .from() table queries against five tables (plus one Storage call), and the browser half of the stack once added about 68 KiB gzipped to pages that had no login at all. This post is the tour of that code, and the defect.
Which package do you import?
The names are confusing because the packages nest. @supabase/ssr depends on supabase-js; you install both only because this repo also builds an admin client from the lower-level package.
| You want | Import | From | Session lives in |
|---|---|---|---|
| Queries in a Client Component | createBrowserClient | @supabase/ssr | cookies (read via document.cookie) |
| Queries in a Server Component, Route Handler or Server Action | createServerClient | @supabase/ssr | the request's cookies |
| A server job that must bypass RLS | createClient | @supabase/supabase-js | nowhere (persistSession: false) |
| A type for a function argument | type SupabaseClient, type User | @supabase/supabase-js | not applicable |
That last row is how the repo actually uses supabase-js in two of its three import sites: src/components/organisms/AccountSettingsSection.tsx imports type { User } and src/lib/lemonsqueezy/webhookDb.ts imports type { SupabaseClient }. Type-only imports cost nothing at runtime. The only runtime import of the raw package is src/lib/supabase/admin.ts.
The three client files
Each is under 40 lines. This is the browser one, copied verbatim from 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!,
);
}
The server one differs in two ways, and both are Next.js 16 specifics. cookies() is async, so the factory is async; and setAll is wrapped in a try/catch, because Server Components cannot write cookies:
// src/lib/supabase/server.ts (abridged)
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.
}
},
},
},
);
}
The swallowed error is intentional, and it only works because src/proxy.ts — Next 16's rename of middleware — calls supabase.auth.getClaims() on every request and writes the rotated cookies onto the response. Remove the proxy and the catch hides a real bug: sessions expire silently.
The admin client is the one that uses the raw package, and it is covered in depth in the service role key post. The relevant line here is its options object: { auth: { persistSession: false, autoRefreshToken: false } }. A client with no user has no session to persist or token to refresh.
The query API: { data, error }, not exceptions
supabase-js queries return { data, error } instead of throwing. Skipping the error check is the most common way to ship a bug that looks like an empty table. This is the whole of getOwnedProductSlugs from src/lib/actions/entitlementsView.ts:
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return [];
const { data, error } = await supabase
.from("entitlement_slots")
.select("kind, product_slug, framework, status")
.eq("user_id", user.id)
.eq("status", "active");
if (error) {
console.error("entitlementsView: slot lookup failed", error);
return [];
}
Three things to read off it:
.select()takes a column list, not*. The server sends four columns, and the code mapsproduct_slugtoproductSlugitself. The column list is also the shape ofdata.- The
.eq("user_id", user.id)is redundant with RLS here. The policy is read-own-only, so the table would return the same rows without it. It stays anyway: on this client RLS is the backstop, and the explicit filter makes the intent readable. On the admin client it is the only protection. - A failed lookup degrades to "owns nothing". That renders the buy path, which is correct for everyone who is not an owner and one extra click for an owner. The page never breaks over a rendering convenience; the real gate is
authorizeDownload, re-run on every/api/downloadrequest.
getUser() or getClaims()
Both verify the caller; they differ in cost. getUser() asks the Auth server about the token. getClaims() validates the JWT's signature itself and returns its claims, so it can avoid that round trip when your project signs with asymmetric keys. Neither is getSession(), which reads the cookie without verifying it — fine for display, wrong for any decision.
The repo's split, measured by grepping src/ outside tests:
| Call | Files | Where |
|---|---|---|
auth.getClaims() | 3 | src/proxy.ts, src/app/dashboard/layout.tsx, src/lib/actions/checkout.ts |
auth.getUser() | 10 | server actions, /api/download, the dashboard page, and the client-side hooks |
The pattern: the proxy and the dashboard layout only need to know whether someone is signed in, so they take the cheaper check. Anything that needs the user's id or email to scope a write — redemption, refunds, account changes — calls getUser() and gets a full User object. That is a judgement call rather than a rule, and getClaims() returns enough in claims.sub that several of the ten could move over; we have not audited that.
The defect: 68 KiB on pages with no login
The first browser-client code imported createClient at module scope. Header renders on every page and useOwnedProducts renders behind every templates grid, so both pulled @supabase/ssr and auth-js into the initial bundle of /, /blog, /docs and /pricing. docs/superpowers/LIGHTHOUSE.md records the size: about 68 KiB gzipped, 255 KiB parsed, on pages with no account UI whatsoever.
Both call sites only touch the client inside an effect, so the import could wait for the effect too. src/lib/supabase/lazyClient.ts is the fix:
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();
}
Two decisions are packed in there. The dynamic import() moves the auth stack out of the initial bundle. The cookie probe then skips the download entirely for visitors who have no sb-*-auth-token cookie — which is every anonymous visitor and every crawler — because there is no session for the client to find. @supabase/ssr stores that cookie with httpOnly: false so createBrowserClient can read it from document.cookie, which is exactly what makes the probe sound.
The probe can only be wrong in one direction, and the cost is small: a signed-in visitor with an unrecognized cookie name sees a stale "Sign in" link until the next document load. It never grants anything, because every real gate is server-side. The Lighthouse pass that shipped this took desktop to 100 across all eight main pages, with the Supabase stack as one of several fixes.
The related trick, one memoized ownership lookup shared by every card on a grid, is in the React caching post.
Mistakes and troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Query returns data: null and nothing throws | The error field was never read | Destructure { data, error } and handle error before data |
| Signed-in user appears signed out after an hour | No proxy refreshing the session cookie on each request | Add src/proxy.ts calling getClaims() and copying rotated cookies onto the response |
Cookies can only be modified in a Server Action or Route Handler | setAll ran during a Server Component render | Wrap cookieStore.set in try/catch and let the proxy do the refresh |
| Whole auth stack in the initial bundle of marketing pages | createClient imported at module scope in a site-wide component | await import() it inside an effect, behind a cookie check |
| Insert fails with a row-level security violation | The browser or server client runs as the user, and no insert policy matches | Add a policy, or route the write through a vetted server path |
createClient import breaks a Client Component build | The admin module carries import "server-only" | Use the browser factory in client code; the build error is the guard working |
Types differ between User and the claims object | getUser() and getClaims() return different shapes | Read claims.sub for the id, or call getUser() when you need the full record |
Frequently asked questions
Do I need both @supabase/supabase-js and @supabase/ssr?
@supabase/ssr depends on supabase-js, so installing it is enough for cookie-based clients. This repo lists both because it builds a cookie-less admin client from the raw package and imports two types from it.
Is it safe to call supabase-js from the browser?
Yes, with the anon key and RLS on. The anon key is public by design and the policies are the security boundary. Never ship the service role key; it bypasses RLS and belongs in server-only code.
Should I use getSession() anywhere?
Not for authorization. It returns whatever is in the cookie without verification. This codebase uses getClaims() or getUser() everywhere and treats getSession() as banned, per its security rules.
Why a lazy import() instead of just a Client Component?
A Client Component still lands in the page's JavaScript bundle. Dynamic import() is what moves the library out of the initial load; the cookie check then avoids loading it at all for signed-out visitors.
Templates in this post
ASoc Ledger is a finance-app marketing site with a live dashboard preview, cashflow tracking and a 50+ integrations grid. ASoc Lens is a product-analytics landing template with a five-service grid, a three-step setup process and a team section. ASoc Magnet is a lead-capture marketing site with channel insights, per-industry targeting and a testimonial wall.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
