Next.js 15 + Supabase

Build a Production-Ready Authentication System in Next.js 15 with Supabase

Authentication is the foundation of every SaaS product — and the most common source of production incidents. This guide walks intermediate developers through a complete, production-ready auth system using Next.js 15 App Router and Supabase, with real code you can ship today.

✓ Middleware✓ OAuth✓ RBAC✓ Server Components

Auth that survives production

Correct session handling, route protection, and role checks — not just a login form.

1. Project Setup

Start with a fresh Next.js 15 project using the App Router. Supabase Auth works best with the @supabase/ssr package, which handles cookie-based sessions correctly across Server Components, Server Actions, Route Handlers, and Middleware.

npx create-next-app@latest my-app --typescript --app --tailwind
cd my-app
npm install @supabase/supabase-js @supabase/ssr

Add these environment variables to .env.local:

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Target audience

This guide assumes you know React and basic Next.js routing. We focus on patterns that work in production — not toy examples that break the moment you deploy to Vercel or Render.

Recommended folder structure:

app/
  (auth)/
    login/page.tsx
    register/page.tsx
    auth/callback/route.ts
  (protected)/
    dashboard/
      layout.tsx
      page.tsx
  layout.tsx
lib/
  supabase/
    client.ts       # Browser client
    server.ts       # Server Component client
    middleware.ts   # Middleware client helper
middleware.ts
contexts/
  AuthContext.tsx

2. Supabase Auth Integration

Create three Supabase clients — one for the browser, one for the server, and a helper for middleware. Each context reads and writes cookies differently; using a single client everywhere is a common mistake.

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_ANON_KEY!
  );
}

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_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Called from a Server Component — middleware will refresh sessions.
          }
        },
      },
    }
  );
}

Middleware helper (lib/supabase/middleware.ts)

import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

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

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // Refresh session — do not remove this line.
  await supabase.auth.getUser();

  return supabaseResponse;
}

3. Email / Password Login

Use Server Actions for login and registration. They run on the server, keep credentials off the client bundle, and integrate cleanly with Supabase's cookie-based sessions.

Server Actions (app/(auth)/actions.ts)

"use server";

import { revalidatePath } from "next/cache";
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: formData.get("email") as string,
    password: formData.get("password") as string,
  });

  if (error) {
    return { error: error.message };
  }

  revalidatePath("/", "layout");
  redirect("/dashboard");
}

export async function signup(formData: FormData) {
  const supabase = await createClient();

  const { error } = await supabase.auth.signUp({
    email: formData.get("email") as string,
    password: formData.get("password") as string,
    options: {
      data: { role: "member" },
    },
  });

  if (error) {
    return { error: error.message };
  }

  redirect("/login?message=Check your email to confirm your account");
}

Login form (app/(auth)/login/page.tsx)

"use client";

import { useActionState } from "react";
import { login } from "../actions";

export default function LoginPage() {
  const [state, formAction, pending] = useActionState(
    async (_prev, formData: FormData) => login(formData),
    { error: "" }
  );

  return (
    <form action={formAction} className="space-y-4">
      <input name="email" type="email" required placeholder="Email" />
      <input name="password" type="password" required placeholder="Password" />
      {state?.error && <p className="text-red-600">{state.error}</p>}
      <button type="submit" disabled={pending}>
        {pending ? "Signing in…" : "Sign in"}
      </button>
    </form>
  );
}

Register form (app/(auth)/register/page.tsx)

"use client";

import { signup } from "../actions";

export default function RegisterPage() {
  return (
    <form action={signup} className="space-y-4">
      <input name="email" type="email" required placeholder="Email" />
      <input
        name="password"
        type="password"
        required
        minLength={8}
        placeholder="Password (min 8 characters)"
      />
      <button type="submit">Create account</button>
    </form>
  );
}
Tip: Enable email confirmation in the Supabase Dashboard under Authentication → Providers → Email. In production, always require verified emails before granting access to protected resources.

4. Google OAuth

OAuth redirects require a callback route that exchanges the authorization code for a session. Configure Google OAuth in the Supabase Dashboard and add your callback URL: https://your-domain.com/auth/callback.

OAuth Server Action

"use server";

import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { headers } from "next/headers";

export async function signInWithGoogle() {
  const supabase = await createClient();
  const origin = (await headers()).get("origin");

  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: "google",
    options: {
      redirectTo: `${origin}/auth/callback`,
    },
  });

  if (error) throw error;
  redirect(data.url);
}

Auth callback route (app/auth/callback/route.ts)

import { NextResponse } from "next/server";
import { createClient } 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") ?? "/dashboard";

  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`);
    }
  }

  return NextResponse.redirect(`${origin}/login?error=auth_callback_failed`);
}

Add a Google sign-in button that calls signInWithGoogle via a form action or a client button with formAction.

5. Middleware

Middleware is your first line of defense. It refreshes expired sessions and redirects unauthenticated users before any page renders. This is critical — without it, users see a flash of protected content or get stuck with stale sessions.

middleware.ts (project root)

import { type NextRequest, NextResponse } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
import { createServerClient } from "@supabase/ssr";

const PUBLIC_ROUTES = ["/", "/login", "/register", "/auth/callback"];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Refresh session cookies on every request
  let response = await updateSession(request);

  const isPublic = PUBLIC_ROUTES.some(
    (route) => pathname === route || pathname.startsWith("/api/public")
  );

  if (isPublic) return response;

  // Check auth for protected routes
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll() {},
      },
    }
  );

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("redirectTo", pathname);
    return NextResponse.redirect(url);
  }

  return response;
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

6. Server Components Authentication

In Next.js 15, prefer fetching the user in Server Components rather than relying on client-side checks. This keeps auth logic on the server and avoids hydration mismatches.

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) redirect("/login");

  const { data: profile } = await supabase
    .from("profiles")
    .select("full_name, role")
    .eq("id", user.id)
    .single();

  return (
    <div>
      <h1>Welcome, {profile?.full_name ?? user.email}</h1>
      <p>Role: {profile?.role}</p>
    </div>
  );
}
Always use getUser(), not getSession(). getUser() validates the JWT with Supabase's auth server.getSession() only reads the local cookie and can be spoofed.

7. Protected Routes & Layouts

Group protected pages under a route group with a shared layout that enforces authentication. This avoids repeating auth checks in every page file.

Protected layout (app/(protected)/layout.tsx)

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { AuthProvider } from "@/contexts/AuthContext";

export default async function ProtectedLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) redirect("/login");

  const { data: profile } = await supabase
    .from("profiles")
    .select("*")
    .eq("id", user.id)
    .single();

  return (
    <AuthProvider user={user} profile={profile}>
      <nav>{/* App navigation */}</nav>
      <main>{children}</main>
    </AuthProvider>
  );
}

Middleware handles the redirect for unauthenticated requests. The layout provides user context to client components below it — a defense-in-depth pattern.

8. Role-Based Access Control (RBAC)

Store roles in a profiles table and enforce them with Supabase Row Level Security (RLS). Never trust client-side role checks alone.

Database schema

create table profiles (
  id uuid references auth.users primary key,
  full_name text,
  role text default 'member' check (role in ('admin', 'member', 'viewer'))
);

alter table profiles enable row level security;

create policy "Users can read own profile"
  on profiles for select
  using (auth.uid() = id);

create policy "Admins can read all profiles"
  on profiles for select
  using (
    exists (
      select 1 from profiles
      where id = auth.uid() and role = 'admin'
    )
  );

Server-side role guard

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function requireRole(allowedRoles: string[]) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) redirect("/login");

  const { data: profile } = await supabase
    .from("profiles")
    .select("role")
    .eq("id", user.id)
    .single();

  if (!profile || !allowedRoles.includes(profile.role)) {
    redirect("/unauthorized");
  }

  return { user, profile };
}

// Usage in an admin page:
export default async function AdminPage() {
  await requireRole(["admin"]);
  return <h1>Admin Panel</h1>;
}

9. Session Handling

Supabase manages JWT refresh automatically when you call getUser() in middleware. Your job is to wire up the cookie helpers correctly and avoid fighting the session lifecycle.

Auth Context for client components

"use client";

import { createContext, useContext } from "react";
import type { User } from "@supabase/supabase-js";

type Profile = { full_name: string | null; role: string };

const AuthContext = createContext<{
  user: User;
  profile: Profile | null;
} | null>(null);

export function AuthProvider({
  user,
  profile,
  children,
}: {
  user: User;
  profile: Profile | null;
  children: React.ReactNode;
}) {
  return (
    <AuthContext.Provider value={{ user, profile }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within AuthProvider");
  return context;
}

Do

  • Refresh sessions in middleware on every request
  • Use getUser() for auth checks
  • Pass user data from Server Components to client via props
  • Set secure cookie options in production (HTTPS)

Don't

  • Store tokens in localStorage
  • Trust getSession() for authorization
  • Skip middleware because "the layout checks auth"
  • Mix multiple Supabase client instances incorrectly

10. Logout

Logout must clear the server-side session and revalidate cached routes. A client-only sign-out leaves stale cookies and lets users appear logged in on refresh.

"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export async function logout() {
  const supabase = await createClient();
  await supabase.auth.signOut();
  revalidatePath("/", "layout");
  redirect("/login");
}

Use it from a form or navigation component:

<form action={logout}>
  <button type="submit">Sign out</button>
</form>

11. Common Pitfalls

Wrong Supabase client in wrong context

Using the browser client in a Server Component won't read cookies correctly. Always use the server client on the server.

Missing middleware session refresh

Without getUser() in middleware, sessions expire silently and users get random logouts or stale auth state.

Client-only route protection

Hiding UI with useEffect doesn't protect data. API routes and Server Components must also enforce auth.

OAuth redirect URL mismatch

Supabase and Google Console redirect URLs must match exactly — including trailing slashes and http vs https.

Roles stored only in JWT metadata

User metadata in the JWT is not secure for authorization. Store roles in a database table protected by RLS.

Forgetting RLS on new tables

Every table with user data needs Row Level Security policies. Without RLS, anyone with the anon key can read all rows via the REST API.

Bonus: Why Authentication Fails in Production

Most auth implementations work locally and break after deployment. Here is why — and how to avoid each failure mode.

1. Cookie domain and HTTPS mismatches

Local development uses localhost without HTTPS. Production uses a real domain with TLS. If your OAuth redirect URLs, Site URL in Supabase, or cookie settings don't match the deployed domain, sessions never persist.

Fix: Set Site URL and Redirect URLs in Supabase Dashboard to your production domain. Add localhost:3000 only for local dev.

2. Edge runtime vs Node.js runtime differences

Middleware runs on the Edge. Some cookie APIs behave differently. The @supabase/ssr package handles this, but custom cookie logic often breaks in Edge deployments.

3. Stale environment variables

Teams deploy with wrong or missing NEXT_PUBLIC_SUPABASE_URL values. The app connects to the wrong project or fails silently. Validate env vars at build time.

// next.config.ts — fail fast on missing vars
const required = ["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"];
required.forEach((key) => {
  if (!process.env[key]) throw new Error(`Missing env: ${key}`);
});

4. No session refresh in middleware

This is the #1 cause of "random logouts." JWTs expire after one hour by default. Without middleware calling getUser(), the refresh token never rotates and users lose their session.

5. Authorization only on the client

A user can call your API routes directly with curl. If only the React app checks roles, your data is exposed. Enforce auth in middleware, Server Components, Server Actions, and RLS policies.

6. Preview deployment URL chaos

Vercel preview URLs change per branch. OAuth providers and Supabase redirect URLs must include wildcard patterns or you must use a staging domain with a fixed URL for OAuth testing.

Production checklist

  • Site URL and redirect URLs configured for all environments
  • Middleware refreshes sessions on every matched request
  • getUser() used everywhere — never getSession() for auth decisions
  • RLS enabled on every user-facing table
  • Roles stored in database, not client-side state
  • Logout clears server session and revalidates layout cache
  • Email confirmation enabled for production
  • Env vars validated at build time

Need help shipping auth in production?

We build and audit authentication systems for SaaS startups and enterprises. Share your stack and we'll help you implement secure auth without the production surprises.

Request Authentication Consultation

Shares
Get Quote
Let's build something powerful

Have a project idea? Let’s turn it into a scalable product.

Book Free Consultation

© 2026 Endurance Softwares. All rights reserved.