Files
supabase__server/docs/ssr-frameworks.md
Tomás Pozo 661329bb9e docs: add SDK documentation and SKILL.md (#20)
* docs: add initial documentation and skills.md

* docs: apply formatting

* docs: update SKILL.md to resolve docs from package location and ship docs with npm

SKILL.md now instructs agents to find documentation in the installed
@supabase/server package (node_modules or repo root) instead of using
relative paths. Added docs/ and SKILL.md to package.json files array
so they ship with npm installs.

* docs: add missing HTTPException import in error-handling example

* docs: fix strictNullChecks issues, duplicate variables, and missing context in examples

- Add non-null assertions (!) after error guards where TS can't narrow
  destructured result tuples
- Split duplicate variable declarations into separate code blocks
- Add missing imports and show where variables like `auth` come from
- Keep { data, error } destructuring pattern consistent with SDK convention

* docs: reframe as runtime-agnostic and add env auto-injection details

- getting-started: replace Edge Function framing with runtime-neutral
  language, explain module worker pattern works across Deno/Bun/Workers,
  add Runtimes section covering all supported environments
- webhooks: replace Deno.env with process.env for portable examples
- environment-variables: add "Auto-injected in" column distinguishing
  Platform vs Local CLI, reframe section headers
- auth-modes: clean up example key values
- core-primitives: clarify "Integration with frameworks" wording
- types: simplify TSDoc for publishable/secret key descriptions

* docs: add SSR frameworks guide and update references

Add docs/ssr-frameworks.md covering the pattern for using core
primitives in Next.js, SvelteKit, Nuxt, and Remix — cookie extraction,
env bridging, JWKS caching, and a complete Next.js adapter example.

Replace the basic SSR example in core-primitives.md with a pointer
to the new dedicated doc. Add SSR row to SKILL.md routing table.

* docs: add disclaimer of new package

* docs: extend explanation on keys env vars

* docs: add platform-specific quick starts to SKILL.md

Split the single generic example into per-platform sections
(Edge Functions, Cloudflare Workers, Hono, SSR Frameworks) so
AI agents pick the correct import specifier for each runtime.
Adds npm: prefix to all Deno examples and a Deno column to the
entry points table. Also adds createSupabaseContext examples.

* docs: add server-to-server quick starts and allow:always guardrails

Add secret key auth and webhook signature verification quick starts
to SKILL.md. Add explicit decision tree for allow:'always' so AI
agents confirm with the user before leaving endpoints unprotected.

* docs: add legacy keys warning, skills install, remove webhook docs

- Add legacy keys warning to SKILL.md (avoid anon/service_role keys)
- Add AI coding skills install section to README
- Add server-to-server quick start with caller code to README
- Add runtimes, documentation table, and named secret keys to README
- Remove verifyWebhookSignature references from all docs
- Delete docs/webhooks.md (code removal in separate PR)

* docs: add verify_jwt = false note for non-user auth modes

Edge Functions require verify_jwt = false in config.toml when
using allow: public, secret, or always — otherwise the platform
rejects requests before the handler runs.

* docs: add edge function recipes and refactor env vars doc

Add recipes for function-to-function calls, pg_net from database,
Stripe webhooks, and generic webhook signature verification.
Document the @supabase/server/wrappers entry point.
Refactor environment-variables.md into Supabase vs non-Supabase sections.

* docs: add security doc covering timing-safe comparison, auth model, CORS

* docs: link auth-modes timing-safe mentions to security.md

* docs: adding 'local cli' to secrets table

This envs will be injected from cli too

* docs: setting Deno as first installation choice

* docs: adding 'verify_jwt=false' disclaimer for non-user auth

* docs: split Deno/Supabase runtime section, merge Deno/Node/Bun

* docs(skills): adding legacy code migration example

* docs(skills): explaining why legacy code should be migrated

* docs: rewrite migration section, improve skill description triggers

---------

Co-authored-by: Kalleby Santos <kalleby_santos@hotmail.com>
2026-03-31 16:14:55 -05:00

9.4 KiB

SSR Frameworks

When you need this

In SSR frameworks like Next.js, Nuxt, SvelteKit, and Remix, the user's JWT doesn't arrive in an Authorization header — it's stored in session cookies managed by @supabase/ssr. The high-level wrappers (withSupabase, createSupabaseContext) expect a standard Request with auth headers, so they don't work directly in SSR contexts.

Instead, use the core primitives to build a lightweight adapter for your framework. The pattern is the same everywhere — only the cookie-reading part changes.

The pattern

Every SSR adapter follows these steps:

  1. Extract the access token from cookies (framework-specific)
  2. Bridge environment variables to the SupabaseEnv shape
  3. Resolve JWKS for JWT verification
  4. Call verifyCredentials with the extracted token
  5. Create clients with createContextClient + createAdminClient
  6. Return a SupabaseContext

Reading Supabase session cookies

@supabase/ssr stores the session in cookies using a chunked, base64-encoded format:

  • Cookie name: sb-<project-ref>-auth-token (the project ref is extracted from your Supabase URL)
  • Chunking: if the session is too large for a single cookie, it's split into sb-<ref>-auth-token.0, .1, .2, etc.
  • Base64 encoding: the cookie value may be prefixed with base64-, indicating base64url encoding

To extract the access token:

const BASE64_PREFIX = 'base64-'

function getAccessTokenFromCookies(
  getCookie: (name: string) => string | undefined,
  supabaseUrl: string,
): string | null {
  // Extract project ref from URL: "https://abc123.supabase.co" → "abc123"
  const ref = new URL(supabaseUrl).hostname.split('.')[0]
  const storageKey = `sb-${ref}-auth-token`

  // Try single cookie first, then chunked
  let raw = getCookie(storageKey) ?? null

  if (!raw) {
    const chunks: string[] = []
    for (let i = 0; ; i++) {
      const chunk = getCookie(`${storageKey}.${i}`)
      if (!chunk) break
      chunks.push(chunk)
    }
    if (chunks.length > 0) raw = chunks.join('')
  }

  if (!raw) return null

  // Decode base64url if needed
  let decoded = raw
  if (decoded.startsWith(BASE64_PREFIX)) {
    try {
      const base64 = decoded
        .substring(BASE64_PREFIX.length)
        .replace(/-/g, '+')
        .replace(/_/g, '/')
      decoded = atob(base64)
    } catch {
      return null
    }
  }

  // Parse the session JSON and extract access_token
  try {
    const session = JSON.parse(decoded)
    return session.access_token ?? null
  } catch {
    return null
  }
}

The getCookie parameter is a function that reads a cookie by name — its implementation depends on your framework (e.g., cookies().get(name)?.value in Next.js, event.cookies.get(name) in SvelteKit).

Environment variable bridging

SSR frameworks often use their own naming conventions for environment variables. Map them to a Partial<SupabaseEnv> that the core primitives expect:

import type { SupabaseEnv } from '@supabase/server'

function resolveEnvFromFramework(): Partial<SupabaseEnv> {
  // Example: Next.js uses NEXT_PUBLIC_* for client-exposed vars
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL
  const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
  const secretKey = process.env.SUPABASE_SECRET_KEY

  return {
    url: url ?? undefined,
    publishableKeys: publishableKey ? { default: publishableKey } : {},
    secretKeys: secretKey ? { default: secretKey } : {},
    // JWKS: either set SUPABASE_JWKS env var, or fetch it (see below)
  }
}

JWKS resolution

JWT verification requires a JWKS (JSON Web Key Set). Two options:

Option 1: Set the SUPABASE_JWKS environment variable. This is auto-available on the Supabase platform and in local CLI. If set, the core primitives pick it up automatically — no extra code needed.

Option 2: Fetch from the well-known endpoint and cache. Useful when deploying to environments where SUPABASE_JWKS isn't set:

import type { SupabaseEnv } from '@supabase/server'

let cachedJwks: SupabaseEnv['jwks'] = null

async function getJwks(supabaseUrl: string): Promise<SupabaseEnv['jwks']> {
  if (cachedJwks) return cachedJwks

  try {
    const res = await fetch(`${supabaseUrl}/auth/v1/.well-known/jwks.json`)
    if (!res.ok) return null
    cachedJwks = await res.json()
    return cachedJwks
  } catch {
    return null
  }
}

The cache lives in module scope, so it persists across requests for the lifetime of the server process. For serverless environments (e.g., Vercel), the cache is per-invocation — consider using an external cache or always setting SUPABASE_JWKS.

Complete example: Next.js adapter

A full adapter for Next.js App Router — works in Server Components, Server Actions, and Route Handlers:

// lib/supabase/context.ts
import { cookies } from 'next/headers'
import {
  verifyCredentials,
  createContextClient,
  createAdminClient,
} from '@supabase/server/core'
import type {
  AllowWithKey,
  SupabaseContext,
  SupabaseEnv,
} from '@supabase/server'

const BASE64_PREFIX = 'base64-'

function getAccessTokenFromCookies(
  cookieStore: Awaited<ReturnType<typeof cookies>>,
  url: string,
): string | null {
  const ref = new URL(url).hostname.split('.')[0]
  const storageKey = `sb-${ref}-auth-token`

  let raw = cookieStore.get(storageKey)?.value ?? null

  if (!raw) {
    const chunks: string[] = []
    for (let i = 0; ; i++) {
      const chunk = cookieStore.get(`${storageKey}.${i}`)?.value
      if (!chunk) break
      chunks.push(chunk)
    }
    if (chunks.length > 0) raw = chunks.join('')
  }

  if (!raw) return null

  let decoded = raw
  if (decoded.startsWith(BASE64_PREFIX)) {
    try {
      const base64 = decoded
        .substring(BASE64_PREFIX.length)
        .replace(/-/g, '+')
        .replace(/_/g, '/')
      decoded = atob(base64)
    } catch {
      return null
    }
  }

  try {
    const session = JSON.parse(decoded)
    return session.access_token ?? null
  } catch {
    return null
  }
}

function resolveNextEnv(): Partial<SupabaseEnv> {
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL
  const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
  const secretKey = process.env.SUPABASE_SECRET_KEY

  return {
    url: url ?? undefined,
    publishableKeys: publishableKey ? { default: publishableKey } : {},
    secretKeys: secretKey ? { default: secretKey } : {},
  }
}

let cachedJwks: SupabaseEnv['jwks'] = null

async function getJwks(supabaseUrl: string): Promise<SupabaseEnv['jwks']> {
  if (cachedJwks) return cachedJwks
  try {
    const res = await fetch(`${supabaseUrl}/auth/v1/.well-known/jwks.json`)
    if (!res.ok) return null
    cachedJwks = await res.json()
    return cachedJwks
  } catch {
    return null
  }
}

export async function createSupabaseContext(
  options: { allow?: AllowWithKey | AllowWithKey[] } = { allow: 'user' },
): Promise<
  { data: SupabaseContext; error: null } | { data: null; error: Error }
> {
  const nextEnv = resolveNextEnv()

  if (!nextEnv.url) {
    return { data: null, error: new Error('Missing SUPABASE_URL') }
  }

  const cookieStore = await cookies()
  const token = getAccessTokenFromCookies(cookieStore, nextEnv.url)

  const jwks = await getJwks(nextEnv.url)
  const env: Partial<SupabaseEnv> = { ...nextEnv, jwks }

  const { data: auth, error } = await verifyCredentials(
    { token, apikey: null },
    { allow: options.allow ?? 'user', env },
  )

  if (error) {
    return { data: null, error }
  }

  const supabase = createContextClient({
    auth: { token: auth!.token },
    env,
  })
  const supabaseAdmin = createAdminClient({ env })

  return {
    data: {
      supabase,
      supabaseAdmin,
      userClaims: auth!.userClaims,
      claims: auth!.claims,
      authType: auth!.authType,
    },
    error: null,
  }
}

Usage

In a Server Component

// app/page.tsx
import { createSupabaseContext } from '@/lib/supabase/context'
import { redirect } from 'next/navigation'

export default async function Home() {
  const { data: ctx, error } = await createSupabaseContext()

  if (error) {
    redirect('/auth/login')
  }

  const { data: todos } = await ctx!.supabase.from('todos').select()

  return (
    <ul>
      {todos?.map((t) => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  )
}

In a Route Handler

// app/api/todos/route.ts
import { createSupabaseContext } from '@/lib/supabase/context'

export async function GET() {
  const { data: ctx, error } = await createSupabaseContext()

  if (error) {
    return Response.json({ message: error.message }, { status: 401 })
  }

  const { data } = await ctx!.supabase.from('todos').select()
  return Response.json(data)
}

With different auth modes

// Public endpoint — no auth required
const { data: ctx } = await createSupabaseContext({ allow: 'always' })

// Accept either user JWT or skip auth
const { data: ctx } = await createSupabaseContext({ allow: ['user', 'always'] })

Adapting for other frameworks

The adapter above is Next.js-specific only in how it reads cookies (await cookies() from next/headers). To adapt for another framework, replace the cookie-reading logic:

  • SvelteKit: event.cookies.get(name) in +page.server.ts or +server.ts
  • Nuxt: useCookie(name) in server routes, or getCookie(event, name) from h3
  • Remix: request.headers.get('cookie') then parse with a cookie library

Everything else — env bridging, JWKS fetching, verifyCredentials, client creation — stays the same.