Set up Supabase Auth: $ARGUMENTS
$ARGUMENTS can include:
- Framework hint: "nextjs" or "vite" (if omitted, auto-detects from package.json)
- Providers: "email", "google", "github", "magic-link" (default: email + magic-link)
- "with-roles" to add role-based access control (user/admin/owner)
- "minimal" for email-only, no OAuth
- Empty — auto-detects framework and asks about providers
Before Starting
- Read
package.jsonto detect framework (Next.js vs Vite) - Read
src/lib/env.tsor.env.exampleto see existing env var patterns - Check if
@supabase/ssris already installed (grep "@supabase" package.json) - Read
src/middleware.ts(Next.js) orsrc/main.tsx(Vite) for existing setup - Read
src/lib/supabase/if it exists — do not overwrite working clients - Check
src/lib/database/schema.tsfor existing user/profile tables
Architecture
Next.js 15 (App Router)
src/lib/supabase/
server.ts ← createServerClient() — SSR-safe, uses cookies()
browser.ts ← createBrowserClient() — singleton for client components
middleware.ts ← refreshSession() helper called from middleware.ts
src/middleware.ts ← Session refresh + route protection
src/app/(auth)/
login/page.tsx ← Sign-in form
signup/page.tsx ← Sign-up form
callback/route.ts ← OAuth + magic link exchange
auth-error/page.tsx
src/hooks/custom/use-auth.ts ← useUser(), useSession(), useSignOut()
src/components/auth/
LoginForm.tsx
SignupForm.tsx
AuthProvider.tsx ← Client-side session listener
Vite + React
src/lib/supabase/
client.ts ← createClient() — browser singleton
src/hooks/use-auth.ts
src/components/auth/
LoginForm.tsx
SignupForm.tsx
AuthGuard.tsx ← Redirect wrapper for protected routes
Step 1 — Install Packages
pnpm add @supabase/ssr @supabase/supabase-js
Note: Never install @supabase/auth-helpers-nextjs — it is deprecated. @supabase/ssr is the correct package.
Step 2 — Environment Variables
Add to .env.local and .env.example:
# --- Supabase ---
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Server-only (service role — NEVER expose to browser):
# SUPABASE_SERVICE_ROLE_KEY=
Add to src/lib/env.ts (Zod schema):
export const env = createEnv({
client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
},
server: {
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(),
},
// ...
});
Step 3 — Supabase Clients (Next.js)
src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/lib/database/types";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
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 {
// Server Component — cookies set by middleware, not here
}
},
},
}
);
}
src/lib/supabase/browser.ts
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/lib/database/types";
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
src/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
const { data: { user } } = await supabase.auth.getUser();
return { supabaseResponse, user };
}
Step 4 — Middleware (Next.js)
Edit src/middleware.ts — add session refresh and protected route logic:
import { type NextRequest, NextResponse } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
// Routes that require authentication
const PROTECTED_ROUTES = [
"/account",
"/dashboard",
"/content/courses",
];
export async function middleware(request: NextRequest) {
const { supabaseResponse, user } = await updateSession(request);
const pathname = request.nextUrl.pathname;
const isProtected = PROTECTED_ROUTES.some((route) =>
pathname.startsWith(route)
);
if (isProtected && !user) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirectTo", pathname);
return NextResponse.redirect(loginUrl);
}
return supabaseResponse;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
Step 5 — Auth Callback Route (Next.js)
Create src/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 redirectTo = searchParams.get("redirectTo") ?? "/";
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(`${origin}${redirectTo}`);
}
}
return NextResponse.redirect(`${origin}/auth-error`);
}
Step 6 — Auth Hook
Create src/hooks/custom/use-auth.ts:
"use client";
import { createClient } from "@/lib/supabase/browser";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import type { User } from "@supabase/supabase-js";
export function useAuth() {
const supabase = createClient();
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
setUser(user);
setLoading(false);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_, session) => {
setUser(session?.user ?? null);
router.refresh();
}
);
return () => subscription.unsubscribe();
}, []);
const signOut = async () => {
await supabase.auth.signOut();
router.push("/login");
};
return { user, loading, signOut };
}
Step 7 — Auth UI Components
src/components/auth/LoginForm.tsx
"use client";
import { createClient } from "@/lib/supabase/browser";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
export function LoginForm() {
const supabase = createClient();
const router = useRouter();
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirectTo") ?? "/";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
setError(error.message);
} else {
router.push(redirectTo);
router.refresh();
}
setLoading(false);
};
return (
<form className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
=> setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
=> setPassword(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
);
}
Step 8 — OAuth Providers (optional)
For Google or GitHub OAuth, add to LoginForm.tsx:
const handleOAuth = async (provider: "google" | "github") => {
await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/callback?redirectTo=${redirectTo}`,
},
});
};
Enable providers in Supabase Dashboard → Authentication → Providers.
Step 9 — RLS User Scoping
For tables that need user-level RLS (not org-level):
-- Enable RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Users can only see their own row
CREATE POLICY "users_own_profile"
ON profiles FOR ALL
USING (auth.uid() = user_id);
For org-scoped tables (multi-tenant), use organization_id via getTenantOrgId() — see src/lib/tenant.ts.
Step 10 — Role-Based Access (with-roles flag)
If "with-roles" was requested, add a user_roles table and helper:
// src/lib/auth/get-user-role.ts
import { createClient } from "@/lib/supabase/server";
export async function getUserRole(): Promise<"user" | "admin" | "owner" | null> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data } = await supabase
.from("user_roles")
.select("role")
.eq("user_id", user.id)
.single();
return (data?.role as "user" | "admin" | "owner") ?? "user";
}
Verify
pnpm typecheck— No TypeScript errorspnpm dev— App starts, login page renders- Create a test account in Supabase Dashboard → Authentication → Users
- Sign in → verify redirect works
- Access a protected route without session → verify redirect to
/login
Anti-Patterns
- NEVER use
@supabase/auth-helpers-nextjs— it is deprecated - NEVER use
cookies().get/set/remove— always usegetAll/setAll - NEVER call
supabase.auth.getSession()in Server Components — usegetUser()(validates JWT server-side) - NEVER skip
router.refresh()after sign-in — Next.js cache must be invalidated - NEVER store
SUPABASE_SERVICE_ROLE_KEYin client-side env vars (noNEXT_PUBLIC_prefix) - NEVER remove the
updateSessioncall from middleware — sessions expire without it