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