# Auth in Next.js (App Router)

> Complete Supabase Auth setup for a Next.js App Router app on SupaHost: browser and server clients, the proxy that refreshes sessions, a callback route, sign-in actions and protected pages.

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

This page sets up cookie-based Supabase Auth in a Next.js App Router app, connected to a SupaHost project. By the end you'll have email and OAuth sign-in, sessions that refresh on their own, and pages that only signed-in users can see. The example app is a recipe box with a private `/kitchen` area.

For the general concepts (why cookies, what PKCE is), see [Server-side auth and SSR](/docs/auth/server-side).

## 1. Install and configure

```bash
npm install @supabase/supabase-js @supabase/ssr
```

Copy the Project URL and the `anon` key from *Dashboard → your project → **Connect** tab → **App***:

```bash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://app-one.acme.supahost.space
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```

Then in *Dashboard → your project → **Auth** tab → **Auth** card*, set **Site URL** to your production address and add these **Redirect URLs**:

```
http://localhost:3000/**
https://recipes.example.com/**
```

Click **Save Auth settings**.

## 2. The browser client

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

Use it in Client Components, for example for Realtime subscriptions. It returns the same instance however often you call it.

## 3. The server client

```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 {
            // Called from a Server Component, which can't set cookies.
            // The proxy below refreshes the session instead.
          }
        },
      },
    }
  )
}
```

Create a new server client in each Server Component, Server Action and Route Handler. It's cheap.

## 4. The proxy that keeps sessions alive

Server Components can't write cookies, so something that runs before them must refresh expired tokens. In Next.js 16 that's `proxy.ts`. On Next.js 15 and earlier, name the file `middleware.ts` and the exported function `middleware`; the code inside 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, headers) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          response = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
          Object.entries(headers ?? {}).forEach(([key, value]) =>
            response.headers.set(key, value)
          )
        },
      },
    }
  )

  // Verifies the token and refreshes it if needed. Don't remove this.
  const { data } = await supabase.auth.getClaims()

  if (!data?.claims && request.nextUrl.pathname.startsWith('/kitchen')) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    const redirect = NextResponse.redirect(url)
    response.cookies.getAll().forEach((cookie) => redirect.cookies.set(cookie))
    return redirect
  }

  return response
}
```

```ts
// proxy.ts (project root, or src/ if you use one)
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|jpeg|svg|webp)$).*)'],
}
```

Two details matter here. Return the `response` that `setAll` last built, or the browser never receives the refreshed cookies. And if you return a different response (like the redirect above), copy the cookies onto it.

## 5. The callback route

OAuth sign-ins and email links (confirmation, magic link, reset password) come back with a `?code=` that must be exchanged for a session:

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

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  const next = searchParams.get('next') ?? '/kitchen'
  const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/kitchen'

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    if (!error) return NextResponse.redirect(`${origin}${safeNext}`)
  }

  return NextResponse.redirect(`${origin}/login?error=callback`)
}
```

## 6. Sign-in actions

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

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

const site = process.env.NEXT_PUBLIC_SITE_URL!

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?error=credentials')
  redirect('/kitchen')
}

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: `${site}/auth/callback` },
  })
  if (error) redirect('/login?error=signup')
  redirect('/login?check=email')
}

export async function signInWithGitHub() {
  const supabase = await createClient()
  const { data } = await supabase.auth.signInWithOAuth({
    provider: 'github',
    options: { redirectTo: `${site}/auth/callback?next=/kitchen` },
  })
  if (data.url) redirect(data.url)
}

export async function signOut() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  redirect('/login')
}
```

```tsx
// app/login/page.tsx
import { signIn, signUp, signInWithGitHub } from './actions'

export default function LoginPage() {
  return (
    <form>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button formAction={signIn}>Sign in</button>
      <button formAction={signUp}>Create account</button>
      <button formAction={signInWithGitHub} formNoValidate>Continue with GitHub</button>
    </form>
  )
}
```

To use GitHub, enable it in the **OAuth providers** card first. See [Sign in with GitHub](/docs/auth/github).

## 7. A protected page

```tsx
// app/kitchen/page.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'

export default async function KitchenPage() {
  const supabase = await createClient()
  const { data } = await supabase.auth.getClaims()
  if (!data?.claims) redirect('/login')

  const { data: recipes } = await supabase
    .from('recipes')
    .select('id, title')
    .order('title')

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

The proxy's redirect is a convenience. The check in the page (and RLS in the database) is what actually protects the data, so keep both.

## Deploying

On Vercel, set the same three environment variables, with `NEXT_PUBLIC_SITE_URL` pointing at your production domain. Add preview URLs to Redirect URLs with a pattern scoped to your team. See [Site URL and redirect URLs](/docs/auth/redirect-urls) and [Deploying on Vercel](/docs/integrations/vercel).

Never put the `service_role` key in a `NEXT_PUBLIC_` variable. If a Route Handler needs admin access, read it from a server-only variable such as `SUPABASE_SERVICE_ROLE_KEY`.

If something doesn't work, see [Troubleshooting Supabase Auth in Next.js](/kb/nextjs-auth-troubleshooting).
