Use SupaHost with SvelteKit

Connect a SvelteKit app to a SupaHost project with @supabase/ssr. A per-request client in hooks.server.ts, typed locals, a server load function, form actions for sign-in and a callback route.

SvelteKit runs load functions and form actions on the server, so the cleanest setup creates a Supabase client per request in hooks.server.ts and reads the session from cookies. @supabase/ssr handles the cookie format and token refresh.

The examples use the recipes table from the quickstart.

Install

npx sv create recipe-box
cd recipe-box
npm install @supabase/supabase-js @supabase/ssr

Add your project keys

Copy the Project URL and anon key from the Connect tab (subtab App) into .env:

PUBLIC_SUPABASE_URL=https://app-one.acme.supahost.space
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...

The PUBLIC_ prefix lets browser code import them from $env/static/public. Keep the service_role key, if you need it, in a private variable read through $env/static/private.

Create a client per request

// src/hooks.server.ts
import { createServerClient } from '@supabase/ssr'
import type { Handle } from '@sveltejs/kit'
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
    cookies: {
      getAll: () => event.cookies.getAll(),
      setAll: (cookiesToSet) => {
        cookiesToSet.forEach(({ name, value, options }) => {
          event.cookies.set(name, value, { ...options, path: '/' })
        })
      },
    },
  })

  // Confirms the token with Auth instead of trusting the cookie as-is.
  event.locals.getUser = async () => {
    const { data, error } = await event.locals.supabase.auth.getUser()
    return error ? null : data.user
  }

  return resolve(event, {
    filterSerializedResponseHeaders(name) {
      return name === 'content-range' || name === 'x-supabase-api-version'
    },
  })
}

SvelteKit requires an explicit path on cookies, which is why the hook adds path: '/'. The filterSerializedResponseHeaders option lets two headers supabase-js reads (for example content-range when you ask for a row count) pass through to the browser.

Tell TypeScript about the new locals:

// src/app.d.ts
import type { SupabaseClient, User } from '@supabase/supabase-js'

declare global {
  namespace App {
    interface Locals {
      supabase: SupabaseClient
      getUser: () => Promise<User | null>
    }
  }
}

export {}

Read data in a load function

// src/routes/+page.server.ts
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals }) => {
  const { data: recipes, error } = await locals.supabase
    .from('recipes')
    .select('id, title, minutes')
    .order('created_at', { ascending: false })

  return { recipes: error ? [] : recipes, user: await locals.getUser() }
}
<!-- src/routes/+page.svelte -->
<script lang="ts">
  let { data } = $props()
</script>

{#if data.user}<p>Signed in as {data.user.email}</p>{/if}

<ul>
  {#each data.recipes as r (r.id)}
    <li>{r.title} ({r.minutes} min)</li>
  {/each}
</ul>

The query runs with the visitor's token, so Row Level Security sees the same user the browser would.

Sign users in with form actions

// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit'
import type { Actions } from './$types'

export const actions: Actions = {
  signin: async ({ request, locals }) => {
    const form = await request.formData()
    const { error } = await locals.supabase.auth.signInWithPassword({
      email: String(form.get('email')),
      password: String(form.get('password')),
    })
    if (error) return fail(400, { message: error.message })
    redirect(303, '/')
  },

  signup: async ({ request, locals, url }) => {
    const form = await request.formData()
    const { error } = await locals.supabase.auth.signUp({
      email: String(form.get('email')),
      password: String(form.get('password')),
      options: { emailRedirectTo: `${url.origin}/auth/callback` },
    })
    if (error) return fail(400, { message: error.message })
    return { message: 'Check your inbox to confirm your email.' }
  },
}
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
  let { form } = $props()
</script>

<form method="POST" action="?/signin">
  <input name="email" type="email" required />
  <input name="password" type="password" required />
  <button>Sign in</button>
  <button formaction="?/signup">Create account</button>
</form>
{#if form?.message}<p>{form.message}</p>{/if}

The confirmation email returns to /auth/callback with a code. Exchange it for a session:

// src/routes/auth/callback/+server.ts
import { redirect } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async ({ url, locals }) => {
  const code = url.searchParams.get('code')
  if (code) {
    const { error } = await locals.supabase.auth.exchangeCodeForSession(code)
    if (!error) redirect(303, '/')
  }
  redirect(303, '/login?failed=1')
}

Protect pages

Check the user in a layout's server load and redirect when it's missing:

// src/routes/account/+layout.server.ts
import { redirect } from '@sveltejs/kit'

export const load = async ({ locals }) => {
  const user = await locals.getUser()
  if (!user) redirect(303, '/login')
  return { user }
}

Allow your redirect URLs

On the project's Auth tab, set Site URL to your production address and add your other origins under Redirect URLs, then click Save Auth settings:

http://localhost:5173/**
https://recipes.example.com/**

Next steps

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us