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
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.tsx2. 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>
);
}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>
);
}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.
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 — nevergetSession()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.