Skip to content

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

Most auth tutorials show the wrong pattern. Here’s what actually works in production.

John Cravey with AIFounder4 min readUpdated Sep 11, 2026

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.

Free estimate · 2 minutes

Read the playbook. Now see it for your business.

The posts are the mechanism. The estimate below sketches the version we'd actually ship for your business, at your scale. About a minute, no opt-in.

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.

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.

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.

Answers

Frequently asked questions

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

Question we did not answer? Ask us directly and we will answer it here.

John Cravey, Founder
Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
Container Queries Everywhere: The CSS Feature That Killed the Breakpoint Mindset
Older post
UTM Strategy for SMBs: The Tagging Conventions That Survive a Year
Keep reading

More from the blog

Next.js·5 min

Server Actions for Lead Forms: Replacing Your API Routes Without Losing Sleep

Server actions cut form code in half and ship progressively enhanced HTML. Here’s how to use them without leaking a database query.

Next.js·4 min

Next/Image with Supabase Storage: The Pattern That Saves 70% of Hero Image Bandwidth

Most teams either skip next/image (and ship 4MB heroes) or misconfigure it (and break Coolify deploys). Here’s the pattern that works.

Next.js·10 min

Structured Data in Next.js: How JSON-LD Gets You Cited by Google and AI

Structured data is how you tell Google and AI what your page means, not just what it says. Here’s the Next.js way to ship it.