Service Business

Supabase Auth With Next.js App Router: The Setup We Actually Ship

4/3/2026 · John

Supabase Auth with Next.js App Router has been a moving target for the last two years. The official docs have rewritten the pattern at least three times. Most blog tutorials show patterns that no longer work or work in dev but break in production. Here’s the setup we run on every FH admin area, refined over six client deploys.

What we’re building

Email magic-link login. Server-side session validation on every protected page. No client-side auth flicker. Role and tenant claims read from the JWT. Sign-out clears everything cleanly. The whole thing in around 100 lines of code across three files.

Why magic-link and not OAuth or password

Magic-link works for SMB admin users without password fatigue. No “forgot password” flow to maintain. Click the email link, you’re in. The downside is the email roundtrip — we accept it because the admin areas are used by 3–10 people per client, not 1000s of consumers. For consumer-facing auth, we’d add Google OAuth as a second option.

Step 1: install the SSR helpers

npm i @supabase/ssr @supabase/supabase-js

Step 2: server and browser clients

// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createSupabaseServerClient() {
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(toSet) {
toSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
},
},
}
);
}

// lib/supabase/client.ts
"use client";
import { createBrowserClient } from "@supabase/ssr";

export function createSupabaseBrowserClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}

Step 3: protect routes in the layout

Use a layout instead of middleware. Layouts run as server components, get the user’s session, redirect if there’s no session. No edge-runtime gotchas, no token-validation crypto inside middleware, no DB calls inside middleware.

// app/admin/layout.tsx
import { redirect } from "next/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const supabase = await createSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
return <>{children}</>;
}

Step 4: the login page

// app/login/page.tsx
"use client";
import { useState } from "react";
import { createSupabaseBrowserClient } from "@/lib/supabase/client";

export default function Login() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const supabase = createSupabaseBrowserClient();

async function submit(e: React.FormEvent) {
e.preventDefault();
const { error } = await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
});
if (!error) setSent(true);
}

if (sent) return <p>Check your email.</p>;
return (
<form onSubmit={submit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
<button>Send link</button>
</form>
);
}

Step 5: the auth callback route

The magic link in the user’s email points to `/auth/callback?code=...`. The callback exchanges the code for a session and redirects into the app.

// app/auth/callback/route.ts
import { NextResponse } from "next/server";
import { createSupabaseServerClient } 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") ?? "/admin";
if (code) {
const supabase = await createSupabaseServerClient();
await supabase.auth.exchangeCodeForSession(code);
}
return NextResponse.redirect(`${origin}${next}`);
}

Step 6: sign out

// inside a server component
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

async function signOut() {
"use server";
const supabase = await createSupabaseServerClient();
await supabase.auth.signOut();
redirect("/login");
}

// in the JSX
<form action={signOut}><button>Sign out</button></form>

Tenant scoping via JWT claims

Once a user is signed in, every read should be scoped to their tenant via RLS. We inject `site_id` into the JWT via a Supabase auth hook that runs server-side on sign-in. Every RLS policy on every tenant-scoped table uses `auth.jwt() ->> 'site_id'`. The same approach is documented in the RLS post.

What to avoid

  • Client-side session checks as the only protection. The auth flicker is bad UX and the boundary isn’t enforced server-side.
  • Middleware for auth on every route. It works but it’s slow, awkward to debug, and the layout pattern is simpler.
  • Storing the user object in React Context. The session is in cookies; read it from the server on every request. Context introduces stale-state bugs.
  • Anonymous Supabase keys checked into client-rendered HTML attributes. Use NEXT_PUBLIC_ env vars and the SSR helper.
getUser() vs getSession()
Always use `supabase.auth.getUser()` on the server, not `getSession()`. `getUser()` validates the JWT against Supabase’s auth server. `getSession()` only checks if the cookie exists. We had a CabCarpentry session-fixation issue early on because we trusted getSession() output — fixed by switching everywhere to getUser().

Magic-link rate-limiting

Supabase rate-limits magic-link sends at the project level. If you have multiple tenants sharing one project (which we do), you can hit the project-level limit faster than you expect. The fix is to handle the “rate limit exceeded” error gracefully in the UI and add Turnstile on the login form to keep abuse traffic off the auth endpoint.

How this lands across FH client work

Every FH admin area uses this exact pattern: server-side auth checks in the layout, magic-link login, JWT claims for tenant scoping, server actions for sign-out. Total auth code across all clients fits in one shared package. The setup has held up across six client launches with zero auth-related security incidents. If your auth is fighting you, book a consultation — the migration from a homegrown auth or NextAuth setup to this pattern usually ships in a single sprint.

The same setup, on your site
Every client admin area we build runs this exact auth pattern. If you need a site with a secure portal rather than a tutorial, start with web design and build.

Frequently asked questions

Why magic links rather than passwords or OAuth?

Because passwords mean storage, reset flows, and support burden, and OAuth means a provider dependency plus account-linking edge cases. A magic link removes both for an audience that checks email anyway. It is not right for everything, but for an operator surface it is the least machinery for the same result.

What are the server and browser clients for?

They read the session from different places: cookies on the server, storage in the browser. Using the wrong one is the most common setup bug, and it presents as a session that exists in one half of the app and not the other, which reads as random logouts.

Where should route protection live?

In the layout for the protected segment, plus a real authorization check wherever data is read. The layout gate is what redirects a browser; the data-layer check is what actually protects the data, and a system with only the first is protected against navigation rather than against access.

Why must the login page live outside the protected segment?

Because a gate that also covers its own redirect target produces an infinite loop: the request is redirected to login, login is protected, and it redirects again. Keeping login outside the matcher is the invariant, and it is worth asserting in a test rather than remembering.

What does the auth callback route do?

It exchanges the code from the emailed link for a session and sets the cookie, then redirects onward. It has to be a route rather than a page because it performs a side effect before rendering anything, and skipping it leaves a link that authenticates in the URL bar and nowhere else.

How should sign-out work?

Clear the session server-side and redirect, rather than clearing browser state alone. Client-only sign-out leaves a valid cookie the server still honours, which produces the worst kind of bug: the user believes they signed out and the next request says otherwise.

How does tenant scoping ride on the session?

As a claim set server-side when the session is issued, which policies can then read directly. The claim must never be derived from something the client supplies, because at that point the tenant boundary is whatever the browser says it is.

Should magic links be rate limited?

Yes. Without a limit the endpoint is an email-sending service for anyone who finds it, which harms your sending reputation as well as the recipients. Rate limit per address and per address family, and log the attempts, because the pattern of abuse is obvious in hindsight and invisible without records.

What should be avoided in this setup?

Reading the session in a client component and treating the result as authorization. Storing tokens anywhere other than the cookie the helpers manage. And protecting routes only in middleware, which is a gate rather than a guarantee and does not cover direct data access.

How do I test an auth setup?

With the enforcement flag on, not off. A dark-flagged auth gate passes every test while the flag is off, which is exactly how a redirect loop or a broken callback reaches production. Test the enabled path: unauthenticated is denied, login is reachable, and the machine path works.

How does a machine caller authenticate?

With a shared secret checked server-side, kept entirely separate from the human session path. Tooling has no browser, so a system with only a session gate locks out its own automation. Fail closed: an unset secret grants nothing rather than everything.

What breaks first when this is set up wrong?

Navigation. The first symptom is usually a session that works on hard refresh and disappears on client-side navigation, which points at the wrong client being used in one half of the app rather than at the auth provider.