Next.js Auth with a Supabase-Compatible Backend

Next.js Supabase auth on the App Router uses the @supabase/ssr package: a browser client for client components, a server client that reads and writes the session cookie for server components and server actions, and a proxy (called middleware before Next.js 16) that refreshes the session on every request. On the server you check the user with auth.getUser(), which asks the Auth server, and Row Level Security in Postgres does the rest.

This guide builds that setup from scratch: sign-up and sign-in with server actions, OAuth with a callback route, and a protected page that reads the user's own rows. It works against Supabase or any backend that implements the same Auth and REST APIs. We use StitchDB in the examples, where supabase-js and @supabase/ssr work by changing the URL and key (compatibility). The file layout follows Supabase's own Next.js example as of November 2026.

#How the pieces fit

FileRunsJob
lib/db/client.tsBrowserClient for client components
lib/db/server.tsServerClient that uses the request's cookies
proxy.ts (or middleware.ts)Every requestRefreshes the session cookie, redirects signed-out users
app/login/actions.tsServerSign-up, sign-in, sign-out as server actions
app/auth/callback/route.tsServerExchanges the OAuth or e-mail code for a session
app/dashboard/page.tsxServerA protected page

The session lives in a cookie (by default sb-<project-ref>-auth-token), so both the server and the browser can read it.

#1. Install and configure

Terminal
npx create-next-app@latest my-app
cd my-app
npm install @supabase/supabase-js @supabase/ssr
Terminal
# .env.local
NEXT_PUBLIC_API_URL=https://<project-ref>.p.stitchdb.com
NEXT_PUBLIC_ANON_KEY=<anon key from Settings > API>

Use the anon key. It's public by design, because RLS decides what it can do. The service role key never goes in a NEXT_PUBLIC_ variable.

In your project's Authentication → URL configuration, set the Site URL and add http://localhost:3000/** and your production URL to Redirect URLs (redirect URLs).

#2. The browser client

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

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_API_URL!,
    process.env.NEXT_PUBLIC_ANON_KEY!,
  )
}

#3. The server client

TypeScript
// lib/db/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_API_URL!,
    process.env.NEXT_PUBLIC_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: cookies are read-only there.
            // Safe to ignore because the proxy refreshes the session.
          }
        },
      },
    },
  )
}

Create a new server client per request. Don't keep one in a module-level variable: it would share one user's cookies with everyone.

#4. The proxy that refreshes sessions

Access tokens expire after an hour by default. Server components can't write cookies, so something has to refresh the token before your pages run. That's the proxy.

TypeScript
// proxy.ts  (Next.js 15 and earlier: middleware.ts with `export async function middleware`)
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

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

  const db = createServerClient(
    process.env.NEXT_PUBLIC_API_URL!,
    process.env.NEXT_PUBLIC_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),
          )
        },
      },
    },
  )

  // Refreshes the session if needed and checks it with the Auth server.
  const { data: { user } } = await db.auth.getUser()

  const path = request.nextUrl.pathname
  if (!user && path.startsWith('/dashboard')) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return response
}

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

Two rules from Supabase's guide that save hours of debugging: don't put code between creating the client and the auth call, and always return the response object that setAll built, or the browser and server cookies drift apart and users get signed out at random. The file name matters too: on Next.js 15 and earlier a proxy.ts is never called, so use middleware.ts there.

getUser or getClaims? Supabase's current docs recommend getClaims(), which verifies the token locally when the project uses asymmetric signing keys. With a shared HS256 secret, getClaims() makes a request to the Auth server, just like getUser(). StitchDB signs tokens with HS256 (roles and JWTs), so the two behave the same there; this guide uses getUser(). Never trust getSession() on the server: it reads the cookie without checking it.

#5. Sign-up, sign-in and sign-out as server actions

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

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

export async function signIn(formData: FormData) {
  const db = await createClient()
  const { error } = await db.auth.signInWithPassword({
    email: String(formData.get('email')),
    password: String(formData.get('password')),
  })
  if (error) redirect('/login?error=' + encodeURIComponent(error.message))
  redirect('/dashboard')
}

export async function signUp(formData: FormData) {
  const db = await createClient()
  const origin = (await headers()).get('origin')
  const { error } = await db.auth.signUp({
    email: String(formData.get('email')),
    password: String(formData.get('password')),
    options: { emailRedirectTo: `${origin}/auth/callback?next=/dashboard` },
  })
  if (error) redirect('/login?error=' + encodeURIComponent(error.message))
  redirect('/login?message=Check your e-mail to confirm your account')
}

export async function signOut() {
  const db = await createClient()
  await db.auth.signOut()
  redirect('/login')
}
TypeScript
// app/login/page.tsx
import { signIn, signUp } from './actions'

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

With Confirm e-mail on (the default), signUp returns no session; the user clicks the link in the e-mail and lands on your callback route.

@supabase/ssr uses the PKCE flow: the provider or e-mail link sends the user back with a code, and your server exchanges it for a session cookie.

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

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

  if (code) {
    const db = await createClient()
    const { error } = await db.auth.exchangeCodeForSession(code)
    if (!error) return NextResponse.redirect(`${origin}${next}`)
  }
  return NextResponse.redirect(`${origin}/login?error=Could not sign you in`)
}

For "Sign in with GitHub", call signInWithOAuth in a server action and redirect to the URL it returns:

TypeScript
export async function signInWithGitHub() {
  const db = await createClient()
  const origin = (await headers()).get('origin')
  const { data, error } = await db.auth.signInWithOAuth({
    provider: 'github',
    options: { redirectTo: `${origin}/auth/callback` },
  })
  if (error) redirect('/login?error=' + encodeURIComponent(error.message))
  redirect(data.url)
}

In GitHub's OAuth app settings, the callback URL is your project's https://<project-ref>.p.stitchdb.com/auth/v1/callback, and the client ID and secret go in Authentication → Sign-in methods (OAuth providers).

#7. A protected page that reads the user's rows

TypeScript
// app/dashboard/page.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/db/server'
import { signOut } from '../login/actions'

export default async function Dashboard() {
  const db = await createClient()
  const { data: { user } } = await db.auth.getUser()
  if (!user) redirect('/login')

  // RLS returns only this user's rows: no user filter needed.
  const { data: todos } = await db
    .from('todos')
    .select('id, task, is_complete')
    .order('created_at', { ascending: false })

  return (
    <main>
      <p>Signed in as {user.email}</p>
      <ul>{todos?.map((t) => <li key={t.id}>{t.task}</li>)}</ul>
      <form action={signOut}><button>Sign out</button></form>
    </main>
  )
}

The table needs RLS and a policy, for example:

SQL
alter table public.todos enable row level security;

create policy "Users manage their own todos" on public.todos
  for all to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

Check the page itself as well as the proxy. The proxy is a convenience for redirects; the getUser() call in the page and RLS in the database are what actually protect the data.

#Adding Stripe

Most Next.js Supabase starters add Stripe next: a checkout session from a server action, a webhook route that records subscriptions, and RLS policies or server checks based on the subscription status. On StitchDB you can skip writing the webhook handler: Stripe sync (Pro plan and up) keeps customers, products, prices, subscriptions and invoices in a stripe schema in your database, with a user_id column taken from the customer's metadata (Stripe sync). For the full pattern, see Stripe subscriptions backend.

#Running it locally

stitchdb start runs Auth, REST and Postgres on your machine without Docker, with a mail catcher at http://127.0.0.1:54324 for confirmation e-mails (local development). Point .env.local at http://127.0.0.1:54321 and the local anon key, and the same code runs offline.

Supabase is a fine choice for this stack too, with more Next.js templates and regions outside Europe. StitchDB fits if you want EU hosting, a lower first paid plan ($15.99) or the app services in one project. For backend options beyond auth, see the Next.js backend guide.

#FAQ

#Does @supabase/ssr work with StitchDB?

Yes. It builds on supabase-js, which talks to the same Auth and REST paths. Use your StitchDB project URL and anon key.

#Should I use middleware.ts or proxy.ts?

proxy.ts on Next.js 16 and later, middleware.ts with a middleware function on 15 and earlier. A proxy.ts on an older version is never called, so sessions never refresh.

#Why do my users get signed out randomly?

Usually the proxy returns a different response object than the one setAll built, or there's code between creating the client and the auth call. Return the response that carries the refreshed cookies.

#Is getSession safe on the server?

No. It reads the cookie without verifying it. Use getUser() (or getClaims()), which checks the token with the Auth server or its signature.

#Where do I put the service role key?

In a server-only environment variable without the NEXT_PUBLIC_ prefix, used only in server code for admin tasks.

Run the starter against a free StitchDB project, or move an existing app with the compatibility guide.