What You'll Build

Supabase Auth gives you email, OAuth and magic-link login. Making it work properly with the Next.js App Router takes a few specific pieces, because Server Components, Server Actions and the browser all need to share the same session through cookies.

This guide uses Supabase's @supabase/ssr package and the current Next.js setup, where the old middleware.ts file is now called proxy.ts (Next.js 16).


🎯 Quick Answer (30-Second Read)

  • Install @supabase/supabase-js and @supabase/ssr.
  • Two clients: a browser client for Client Components, a server client (reads/writes cookies) for Server Components, Actions and Route Handlers.
  • A proxy (proxy.ts, formerly middleware.ts) refreshes the auth token on each request, because Server Components can't write cookies.
  • On the server, check auth with supabase.auth.getClaims() (or getUser()), not getSession(), which doesn't revalidate the token.
  • Protect data with Row Level Security, not just page redirects.

How the Pieces Fit

flowchart TB B["🧑 Browser
(auth cookies)"] -->|"every request"| PX["proxy.ts
refresh token, update cookies"] PX --> SC["Server Components
server client: read user"] PX --> SA["Server Actions / Route Handlers
server client: check user"] B --> CC["Client Components
browser client: sign in/out"] SC --> SB[("Supabase
Auth + Postgres with RLS")] SA --> SB CC --> SB

Step 1: Install and Configure

npm install @supabase/supabase-js @supabase/ssr

.env.local:

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key

Use the publishable (anon) key here, never the service-role key, which bypasses all security.


Step 2: The Browser Client

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

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}

Step 3: The Server Client

// lib/supabase/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_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Called from a Server Component: ignore, the proxy refreshes sessions
          }
        },
      },
    }
  );
}

Create a new server client per request; don't share one globally.


Step 4: The Proxy (Refreshes Sessions)

// proxy.ts (project root; named middleware.ts before Next.js 16)
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

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

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll: () => 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 token if needed. Don't remove.
  const { data } = await supabase.auth.getClaims();

  if (!data?.claims && request.nextUrl.pathname.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)$).*)"],
};

Step 5: Sign In and Sign Up (Server Actions)

// app/login/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export async function login(formData: FormData) {
  const supabase = await createClient();
  const { error } = await supabase.auth.signInWithPassword({
    email: String(formData.get("email")),
    password: String(formData.get("password")),
  });
  if (error) redirect("/login?error=Invalid%20credentials");
  redirect("/dashboard");
}
// app/login/page.tsx
import { login } from "./actions";

export default function LoginPage() {
  return (
    
); }

Step 6: Protected Page

// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export default async function Dashboard() {
  const supabase = await createClient();
  const { data, error } = await supabase.auth.getClaims();
  if (error || !data?.claims) redirect("/login");

  const { data: notes } = await supabase.from("notes").select("*"); // RLS applies
  return 

Welcome, {data.claims.email}

; }

Security Checklist

The proxy isn't your security boundary. It redirects pages, but Server Actions and Route Handlers can be called directly. Check the user inside each one, and enforce access in the database with Row Level Security.
  • Use getClaims() / getUser() on the server, not getSession(), to trust identity
  • Enable RLS on every table with user data (Fix Supabase RLS Policy Error)
  • Never expose the service-role key to the browser
  • Check auth inside every Server Action and Route Handler
  • Add your site URL and redirect URLs in Supabase Auth settings (for OAuth and magic links)

Frequently Asked Questions

Why do I need a proxy (middleware) for Supabase Auth in Next.js?

Server Components can't write cookies, so they can't save a refreshed auth token. The proxy runs before each request, refreshes the session if needed, and writes updated cookies to both the request and response so Server Components see a valid session.

Should I use getSession or getUser on the server?

Don't trust getSession on the server, because it reads the session from cookies without revalidating it. Use getClaims, which verifies the token, or getUser, which checks with the Supabase Auth server, before trusting a user's identity.

Is @supabase/auth-helpers-nextjs still used?

No. Supabase replaced the auth-helpers packages with @supabase/ssr, which works across server-rendering frameworks using cookie-based sessions. New projects should use @supabase/ssr.


Conclusion

Supabase Auth in the Next.js App Router needs four things: a browser client, a per-request server client wired to cookies, a proxy that refreshes sessions, and auth checks with getClaims() wherever data is read or changed. Combine them with Row Level Security and your app is protected at every layer.


Sources

Last verified: 2 November 2026.


Related reads: Fix Supabase RLS Policy Error · Next.js Server Actions Tutorial · JWT vs Session Auth · Supabase Storage Upload Guide

I write practical guides for developers. Follow on LinkedIn or X, or invite me to speak.