# 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.

Source: https://supahost.app/docs/getting-started/nextjs · Updated 2026-11-11

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](/docs/getting-started/quickstart). Create it first if you want to follow along.

## Install

```bash
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`:

```bash
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.

```ts
// 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!
  )
}
```

```ts
// 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.

```ts
// 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
}
```

```ts
// 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

```tsx
// 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:

```ts
// 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')
}
```

```tsx
// 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:

```ts
// 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:

```ts
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:

```text
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

- [Auth in Next.js (App Router)](/docs/auth/nextjs) covers OAuth, protected layouts and sign-out
- [Deploying your app on Vercel](/docs/integrations/vercel)
- [Generating TypeScript types](/docs/api/generating-types)
- [Troubleshooting Supabase Auth in Next.js](/kb/nextjs-auth-troubleshooting)
