Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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 wantImportFromSession lives in
Queries in a Client ComponentcreateBrowserClient@supabase/ssrcookies (read via document.cookie)
Queries in a Server Component, Route Handler or Server ActioncreateServerClient@supabase/ssrthe request's cookies
A server job that must bypass RLScreateClient@supabase/supabase-jsnowhere (persistSession: false)
A type for a function argumenttype SupabaseClient, type User@supabase/supabase-jsnot 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 maps product_slug to productSlug itself. The column list is also the shape of data.
  • 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/download request.

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:

CallFilesWhere
auth.getClaims()3src/proxy.ts, src/app/dashboard/layout.tsx, src/lib/actions/checkout.ts
auth.getUser()10server 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

SymptomCauseFix
Query returns data: null and nothing throwsThe error field was never readDestructure { data, error } and handle error before data
Signed-in user appears signed out after an hourNo proxy refreshing the session cookie on each requestAdd src/proxy.ts calling getClaims() and copying rotated cookies onto the response
Cookies can only be modified in a Server Action or Route HandlersetAll ran during a Server Component renderWrap cookieStore.set in try/catch and let the proxy do the refresh
Whole auth stack in the initial bundle of marketing pagescreateClient imported at module scope in a site-wide componentawait import() it inside an effect, behind a cookie check
Insert fails with a row-level security violationThe browser or server client runs as the user, and no insert policy matchesAdd a policy, or route the write through a vetted server path
createClient import breaks a Client Component buildThe 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 objectgetUser() and getClaims() return different shapesRead 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.

Keep reading

Tutorial8 min read

Supabase Local Dev: The Codebase That Never Runs supabase start

Eight migrations, six RLS policies and 357 tests in 3.3 seconds — with no Docker and no local stack. What that buys, and exactly where it stops working.

Read more
Tutorial11 min read

Supabase Migrations: Eight Files, and Four of Them Are Fixes

An audit of this storefront's eight commerce migrations — three create, five change or fix — plus the three habits that make one safe to re-run.

Read more