Use SupaHost with Next.js

Connect a Next.js App Router app to a SupaHost project with @supabase/ssr. Browser and server clients, the session-refresh proxy, a server-rendered query and email sign-in.

This guide sets up a Next.js App Router app against a SupaHost project. Sessions are kept in cookies with @supabase/ssr, so Server Components, Server Actions and Route Handlers all know who the user is.

The examples read the recipes table from the quickstart. Create it first if you want to follow along.

Install

npx create-next-app@latest recipe-box
cd recipe-box
npm install @supabase/supabase-js @supabase/ssr

Add your project keys

Copy the Project URL and anon key from your project's Connect tab (subtab App) into .env.local:

NEXT_PUBLIC_SUPABASE_URL=https://app-one.acme.supahost.space
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...

Both values are fine in browser code. Never add the service_role key with a NEXT_PUBLIC_ prefix; if you need it, read it only in server code under a name like SUPABASE_SERVICE_ROLE_KEY.

Create the clients

You need two small helpers: one for code that runs in the browser and one for code that runs on the server.

// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
// lib/supabase/server.ts
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_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Server Components can't write cookies. The proxy below
            // refreshes the session, so this is safe to ignore.
          }
        },
      },
    }
  )
}

Create a new server client per request. Don't share one in a module-level variable, because it holds the cookies of whoever made the request.

Refresh sessions in the proxy

Access tokens expire (after an hour by default, configurable on the Auth tab). Server Components can't write cookies, so a proxy refreshes the token before the page renders. In Next.js 16 the file is proxy.ts and exports proxy; in Next.js 15 and earlier it's middleware.ts exporting middleware. The body is the same.

// lib/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let response = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          response = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  // Validates the token with Auth and refreshes it if needed.
  // Keep this call directly after createServerClient.
  await supabase.auth.getUser()

  return response
}
// proxy.ts (project root)
import type { NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'

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

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

Read data in a Server Component

// app/recipes/page.tsx
import { createClient } from '@/lib/supabase/server'

export default async function RecipesPage() {
  const supabase = await createClient()
  const { data: recipes, error } = await supabase
    .from('recipes')
    .select('id, title, minutes')
    .order('created_at', { ascending: false })

  if (error) return <p>Couldn't load recipes.</p>

  return (
    <ul>
      {recipes.map((r) => (
        <li key={r.id}>
          {r.title} ({r.minutes} min)
        </li>
      ))}
    </ul>
  )
}

The query runs with the signed-in user's token, so Row Level Security applies exactly as it would in the browser.

Sign users in

A form with two Server Actions handles sign-in and sign-up:

// app/login/actions.ts
'use server'

import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'

export async function signIn(formData: FormData) {
  const supabase = await createClient()
  const { error } = await supabase.auth.signInWithPassword({
    email: String(formData.get('email')),
    password: String(formData.get('password')),
  })
  if (error) redirect('/login?failed=1')
  redirect('/recipes')
}

export async function signUp(formData: FormData) {
  const supabase = await createClient()
  const { error } = await supabase.auth.signUp({
    email: String(formData.get('email')),
    password: String(formData.get('password')),
    options: { emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` },
  })
  if (error) redirect('/login?failed=1')
  redirect('/login?check-email=1')
}
// app/login/page.tsx
import { signIn, signUp } from './actions'

export default function LoginPage() {
  return (
    <form>
      <input name="email" type="email" placeholder="Email" required />
      <input name="password" type="password" placeholder="Password" required />
      <button formAction={signIn}>Sign in</button>
      <button formAction={signUp}>Create account</button>
    </form>
  )
}

Set NEXT_PUBLIC_SITE_URL to http://localhost:3000 locally and to your real domain in production.

The confirmation email links back to /auth/callback with a code. Exchange it for a session in a Route Handler:

// app/auth/callback/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    if (!error) return NextResponse.redirect(`${origin}/recipes`)
  }
  return NextResponse.redirect(`${origin}/login?failed=1`)
}

To protect a page, check the user on the server and redirect:

const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')

Use getUser() (or getClaims()) for these checks, not getSession(). The session in a cookie hasn't been verified; getUser() asks Auth to confirm the token.

Allow your redirect URLs

On the project's Auth tab, set Site URL to your production address and add each place users may return to under Redirect URLs, one per line:

http://localhost:3000/**
https://recipes.example.com/**
https://*.vercel.app/**

Click Save Auth settings; changes apply within about a minute. Without this, confirmation and OAuth links fall back to the Site URL.

Next steps

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