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 EleviFounder4 min read

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.

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.

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·6 min

Next.js 16.1 in Production: The Migration Playbook We Run on Every FH Site

Next 16.1 is the lean target. Here’s the exact migration we run, what breaks, and what to delete after.