October 1, 2026

Supabase and Next.js video: what runs on the server and what runs in the browser

Vijaysing Prakash Patil
Vijaysing Prakash Patil
Software Engineer

The Supabase Next.js quickstart gets you to a working sign-in in a few minutes, and the template it generates is current: a browser client, a server client, a middleware file, and the @supabase/ssr package underneath all three. What it never says is which key each of those files may hold, and that matters more than it sounds, because two of the three run on the server while one ships to every visitor.

The leak people picture is not the one that happens. Next.js only inlines environment variables named NEXT_PUBLIC_*, so a secret read as process.env.SUPABASE_SECRET_KEY from client code evaluates to undefined and the value is nowhere in the bundle. What leaks instead is a prefix somebody added to make a build error go away, or a server-side query whose rows get handed across to a Client Component as a prop.

We wire this integration into Next.js apps often enough that the same questions keep coming back at us. Here are the answers we give, in order: which key belongs in which client, why the middleware is there, why getSession() is the wrong call on the server, and what a video app has to keep behind the boundary.

Decide which key each client may hold

Supabase issues two kinds of key and the difference is the whole security model. The publishable key, sb_publishable_... in new projects and still called the anon key in older ones, is designed to be public, and row-level security is the only thing standing between it and your data. The secret key, sb_secret_..., and its legacy equivalent service_role, bypass row-level security entirely.

The legacy pair is on the way out, because Supabase is deprecating `anon` and `service_role` by the end of 2026 in favour of publishable and secret keys, which are short strings rather than JWTs. If a tutorial tells you to copy a long value beginning with eyJ, it was written for the old keys. Both systems work at once, so you can migrate one client at a time rather than all at once.

One Next.js rule covers the rest of it. Next.js inlines any environment variable named NEXT_PUBLIC_* into the client bundle at build time, so that prefix is a promise that the value is public, and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY is correct. A secret key carrying that prefix is published rather than configured, and adding the prefix to make an error disappear is the single most common way this goes wrong.

The publishable key does nothing useful on its own if the tables are closed, and enabling row-level security with no policies denies every role except the service role. We tell people to do that before anything else, and our Supabase integration guide starts there for that reason.

Supabase clients: Next.js server and browser

The browser client comes from createBrowserClient and belongs to Client Components, where it holds the publishable key and reads the session cookie the browser already has.

typescript
// lib/supabase/client.ts, imported only by Client Components
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  )
}

The server client comes from createServerClient and belongs to Server Components, Route Handlers and Server Actions. You hand it the request's cookie store rather than a module-level global, because one Node process serves many users at once and a shared client would mix their sessions together.

typescript
// lib/supabase/server.ts, never imported by a Client Component
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_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options))
          } catch {
            // Called from a Server Component, which cannot write cookies.
            // Safe to swallow when the middleware is refreshing sessions.
          }
        },
      },
    }
  )
}

Note that neither client holds the secret key, which is deliberate. A Route Handler that genuinely needs to bypass row-level security builds its own client with the secret, and in our examples that client never leaves the handler it was created in.

Next.js middleware and the Supabase session

That empty catch is why this file exists. A Server Component can read cookies but cannot write them, so it has nowhere to put a session token that expired during the render, and without help the user gets signed out at intervals nobody can reproduce. When somebody tells us their users are being logged out at random, this file is the first thing we ask about. The middleware runs first, refreshes the token, and writes the new cookie onto both the request and the response.

typescript
// middleware.ts, renamed to proxy.ts in Next.js 16
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'

export async function middleware(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

updateSession builds a server client over request.cookies, verifies the session, and returns the response object that setAll last built. If you return a different response, copy the cookies onto it first, because an earlier response does not carry the refreshed ones and the browser falls out of sync with the server.

Guides written before @supabase/ssr use a single createClient and put 'use client' on the root layout, which turns the whole tree into a client tree and cannot refresh a session cookie from a server render at all.

Never trust getSession on the server

Use getClaims() in server code, and never getSession(). It is the first thing we grep for when we read somebody's server code. getSession() reads the session straight out of the cookie without revalidating it, and anyone can forge a cookie, so a server render that trusts one can be talked into rendering another user's page. getClaims() verifies the token's signature on every call, locally against a cached copy of your project's public keys when the project uses asymmetric signing keys, and against the Auth server when it still uses a symmetric secret.

getUser() is the other safe option, because it makes a network call to the Auth server and hands back a server-confirmed user record, which is what you want when you need the record rather than a yes or a no. Keep getSession() for the one job it is actually for: reading the raw access token when you have to forward it to another service.

This lands harder in a video app than in a blog. The call deciding whether this viewer may watch this asset is the same call that mints a signed playback token, so minting one against an unverified claim hands our playback out to whoever asked for it.

What can safely reach the browser

We use the table below as a review checklist, and every row answers one question: where may this exist at all. A cell reading "Never" means the value or the call has no legitimate path into the browser bundle, rather than that it is merely discouraged.

Piece of the appServerBrowser
Supabase publishable (anon) keyAllowedYes, by design
Supabase browser clientNoYes
Supabase server client, cookie-backedYesNever
Supabase secret (service-role) keyYesNever
FASTPIX_TOKEN_ID and FASTPIX_TOKEN_SECRETYesNever
FASTPIX_WEBHOOK_SECRETYesNever
The FastPix signing key, private halfYesNever
Creating an upload sessionYesNo
Sending the file bytesNoYes, straight to FastPix
Signing a playback tokenYesNever
Playing the videoNoYes
Reading fastpix.mediaYesOnly through a view in public
Reading fastpix.uploadsYesOnly through a view that excludes url
Reading live_streams, webhook_events, sync_stateYesNever

None of this is specific to FastPix, because any vendor's API token belongs on the server and any vendor's file upload is better sent from the browser. What is specific is the last two rows, because our fastpix schema has tables that carry credentials in their own columns.

The secret boundary in a video app

Your Next.js app now holds two sets of credentials, since the Supabase secret key bypasses row-level security while our token pair authenticates calls that can create, list and delete media. Our rule for both is the same and it has no exceptions: they stay server side, and neither ever takes a NEXT_PUBLIC_ prefix.

Three handoffs carry everything the browser needs.

  • Upload. A Route Handler asks FastPix for an upload session and returns the signed upload URL, then the browser sends the bytes to that URL. The file never travels through your Next.js server, so no request body limit applies to it.
  • Playback. A public asset needs only a playback ID. A private one also needs a signed playback token, a JWT your server signs with a FastPix signing key, which is a separate RSA key pair from the API credentials. FastPix stores the public half and verifies the token before serving the stream.
  • Reads. Server-side reads use the secret key and see everything, while browser reads use the publishable key and see only what a view exposes.

Those rows arrive from four edge functions rather than from your Next.js app: fastpix-webhook verifies each delivery and enqueues it, fastpix-worker drains the pgmq queue and projects the record, and fastpix-reconcile and fastpix-backfill run on two cron jobs to repair what a missed webhook left behind. Fragment events such as track and playback ID changes are recorded but not projected, because video.media.updated follows carrying the full record.

That last handoff is where a video schema differs from a normal one. fastpix.live_streams holds streamKey, srtSecret, srtPlaybackStreamId and srtPlaybackSecret, and anyone with those values can broadcast into your account, but a row policy filters rows and cannot hide a column, so those tables need a view or a column grant rather than a policy. Row-level security for video tables in Supabase has the policy for each of the five tables and the view that keeps the stream key server side.

Run one upload across the boundary

Run npx @fastpix/supabase init, which creates the fastpix schema, four edge functions, the pgmq queue and two cron jobs. Set FASTPIX_WEBHOOK_SECRET, FASTPIX_TOKEN_ID and FASTPIX_TOKEN_SECRET in the function environment, and the fastpix_functions_url and fastpix_service_role_key secrets in Supabase Vault. Then push one file through from a Route Handler and run three checks.

bash
next build

# Neither secret should appear in a client chunk.
grep -r "sb_secret_" .next/static/chunks
grep -r "$(grep '^FASTPIX_TOKEN_SECRET=' supabase/functions/.env | cut -d= -f2-)" .next/static/chunks

# No server-only module should have been pulled into one either.
grep -rl "createServerClient" .next/static/chunks

The grep proves a negative and it is worth being exact about which one. It cannot catch a secret Next.js refused to inline, because that value was never in the chunk. It does catch what actually happens: a key that picked up a NEXT_PUBLIC_ prefix, or a literal pasted into source.

The third check is a query rather than a grep. Read your view as the authenticated role, confirm you see only your own rows, and confirm the result carries no url, streamKey, srtSecret or srtPlaybackSecret column. Our Supabase integration guide has the CLI steps, the schema and the variables, and wiring the boundary against a real project needs only the free plan, no card.

Frequently Asked Questions (FAQs)

Should I use getUser or getSession in a Next.js Server Component?

Neither, as the first choice. Supabase's current guidance is getClaims(), which verifies the access token's signature on every call, and never getSession() in server code, because that reads the session out of the cookie without revalidating it and anyone can forge a cookie. Use getUser() when you need a fresh, server-confirmed user record, and getSession() only when you need the raw access token to forward somewhere else.

Can I use the Supabase service-role key in a Next.js Server Component?

The key stays on the server, so holding it there leaks nothing. The risk is the return value, because anything a Server Component passes to a Client Component as a prop is serialized into the page payload, so a query that bypasses row-level security can print rows into the HTML. Filter the result before it crosses that line, or run the query in a Route Handler instead.

Why does the Supabase Next.js template include a middleware file?

A Server Component can read cookies but cannot write them, so it cannot store a refreshed session token. The middleware runs first, refreshes the token, and writes the new cookie onto both the request and the response. Remove it and signed-in users get logged out mid-session. Next.js 16 renames the file to proxy.ts, and the Supabase code inside is unchanged.

Do I need the FastPix API token in the browser to play a video?

No. FASTPIX_TOKEN_ID and FASTPIX_TOKEN_SECRET authenticate calls to the FastPix API and belong on the server only. A player needs a playback ID, and a private asset also needs a signed playback token. That token is a JWT signed with a FastPix signing key, a separate RSA key pair, not with the API secret. Keep the private key and the API credentials server side, and never give either a NEXT_PUBLIC_ prefix.

What happens if I import the Supabase server client into a Client Component?

The module joins the browser bundle, and the cookie helpers it imports usually fail to resolve at build time, which is the good outcome because the build stops. Next.js does not inline a variable without the NEXT_PUBLIC_ prefix, so a secret read through process.env is undefined in that module rather than printed into the chunk. What does ship is any literal pasted into source, or any value somebody gave the prefix to silence an error.

Which fastpix tables can a Next.js Client Component read?

None of them directly. The synced tables carry no app user column, so a policy cannot scope rows to the signed-in user on its own. Keep your own table keyed by mediaId with the owner and any titles or permissions, then expose a view in public that joins your table to fastpix.media and selects only safe columns. Put the policy on your table. As for the rest: fastpix.uploads holds the signed upload url, and live_streams holds streamKey, srtSecret, srtPlaybackStreamId and srtPlaybackSecret. webhook_events stores raw payloads that repeat those values, and sync_state is backfill and reconcile bookkeeping. None of those should be reachable with the publishable key.

Are older Supabase Next.js tutorials still usable?

Check two things: whether the code imports @supabase/ssr, and whether it tells you to copy a long key starting with eyJ. Guides that predate the package use a single createClient and put 'use client' on the root layout, which turns the whole tree into a client tree and cannot refresh a session cookie from a server render. A long eyJ key is the legacy anon or service_role pair, which Supabase is deprecating by the end of 2026.

Share

Stay Ahead of Video
Streaming Trends

Start shipping video today.