.claude/skills/nextjs-supabase-ssr/SKILL.md
Master Supabase SSR patterns for Next.js 15 App Router in RidenDine. Use when: (1) setting up Supabase clients, (2) implementing server-side auth, (3) protecting routes with middleware, (4) handling auth state changes, (5) debugging "user not defined" or session issues. Key insight: Different Supabase client factories for Server Components, Server Actions, Route Handlers, and Client Components - each with different cookie handling.
npx skillsauth add Ritenoob/ridedine nextjs-supabase-ssrInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
RidenDine web and admin apps use Next.js 15 App Router with Supabase Auth. Supabase auth uses HTTP-only cookies for session management. Different contexts (Server Components, Client Components, Server Actions, Route Handlers) require different Supabase client configurations to correctly read/write cookies.
Wrong approach: Single supabaseClient.ts file breaks SSR or leaks cookies to client.
Right approach: Context-specific client factories.
Use this skill when:
Apps:
apps/web/)apps/admin/)Auth Flow:
Location: apps/web/lib/supabase/server.ts
Use Cases:
Example Implementation:
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 {
// Server Component (read-only) - ignore set attempts
}
},
},
}
);
}
Usage in Server Component:
// apps/web/app/page.tsx (Server Component)
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function HomePage() {
const supabase = await createClient();
// Get current user
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
// Fetch user-specific data (RLS automatically filters by auth.uid())
const { data: orders } = await supabase
.from('orders')
.select('*')
.eq('customer_id', user.id)
.order('created_at', { ascending: false });
return (
<div>
<h1>Welcome, {user.email}</h1>
<OrdersList orders={orders} />
</div>
);
}
Location: apps/web/lib/supabase/server.ts (same file, different function)
Use Cases:
Example Implementation:
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createActionClient() {
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) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
}
);
}
Usage in Server Action:
// apps/web/app/login/actions.ts
'use server';
import { createActionClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export async function login(formData: FormData) {
const supabase = await createActionClient();
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
};
const { error } = await supabase.auth.signInWithPassword(data);
if (error) {
return { error: error.message };
}
redirect('/dashboard');
}
export async function signup(formData: FormData) {
const supabase = await createActionClient();
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
};
const { error } = await supabase.auth.signUp(data);
if (error) {
return { error: error.message };
}
redirect('/dashboard');
}
export async function signout() {
const supabase = await createActionClient();
await supabase.auth.signOut();
redirect('/login');
}
Form Component (Client Component):
// apps/web/app/login/page.tsx
'use client';
import { login } from './actions';
import { useState } from 'react';
export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
async function handleSubmit(formData: FormData) {
const result = await login(formData);
if (result?.error) {
setError(result.error);
}
}
return (
<form action={handleSubmit}>
{error && <p className="error">{error}</p>}
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Log in</button>
</form>
);
}
Location: apps/web/lib/supabase/route-handler.ts
Use Cases:
Example Implementation:
import { createServerClient } from '@supabase/ssr';
import { type NextRequest, NextResponse } from 'next/server';
export function createClient(request: NextRequest) {
// Create response (for setting cookies)
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, options }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
return { supabase, supabaseResponse };
}
Usage in Route Handler:
// apps/web/app/api/profile/route.ts
import { createClient } from '@/lib/supabase/route-handler';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { supabase, supabaseResponse } = createClient(request);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
return Response.json(profile, {
headers: supabaseResponse.headers, // Include cookie updates
});
}
Location: apps/web/lib/supabase/client.ts
Use Cases:
Example Implementation:
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
Usage in Client Component:
// apps/web/components/OrdersRealtime.tsx
'use client';
import { createClient } from '@/lib/supabase/client';
import { useEffect, useState } from 'react';
export function OrdersRealtime({ initialOrders }: { initialOrders: any[] }) {
const [orders, setOrders] = useState(initialOrders);
const supabase = createClient();
useEffect(() => {
// Subscribe to real-time updates
const channel = supabase
.channel('orders')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'orders',
},
(payload) => {
if (payload.eventType === 'INSERT') {
setOrders((prev) => [payload.new, ...prev]);
} else if (payload.eventType === 'UPDATE') {
setOrders((prev) =>
prev.map((order) =>
order.id === payload.new.id ? payload.new : order
)
);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase]);
return (
<ul>
{orders.map((order) => (
<li key={order.id}>{order.status}</li>
))}
</ul>
);
}
Location: apps/web/middleware.ts
Purpose: Redirect unauthenticated users to login
Example Implementation:
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(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, options }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
// Refresh session if expired
const {
data: { user },
} = await supabase.auth.getUser();
// Protected routes
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
// Redirect authenticated users from login page
if (user && request.nextUrl.pathname === '/login') {
const url = request.nextUrl.clone();
url.pathname = '/dashboard';
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
Using RLS + Server Components:
// apps/web/app/admin/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function AdminPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
// Fetch user profile to check role
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single();
if (profile?.role !== 'admin') {
redirect('/'); // Not authorized
}
// Admin-specific data (RLS policies enforce role-based access)
const { data: chefs } = await supabase.from('chefs').select('*');
return (
<div>
<h1>Admin Dashboard</h1>
<ChefsList chefs={chefs} />
</div>
);
}
Location: apps/web/lib/AuthProvider.tsx
Purpose: Listen for auth state changes (login, logout) and sync across tabs
Example Implementation:
'use client';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const supabase = createClient();
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_OUT') {
router.push('/login');
} else if (event === 'SIGNED_IN' && session) {
router.push('/dashboard');
}
// Refresh Server Component data
router.refresh();
});
return () => subscription.unsubscribe();
}, [router, supabase]);
return <>{children}</>;
}
Add to Root Layout:
// apps/web/app/layout.tsx
import { AuthProvider } from '@/lib/AuthProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
Symptom: user is always null in Server Components
Cause: Using wrong Supabase client or cookies not accessible
Fix:
createClient() from lib/supabase/server.tsawait cookies() is called (Next.js 15 requirement)NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are setSymptom: User logged out after page refresh
Cause: Server Action client not setting cookies
Fix:
createActionClient() in Server Actions (not createClient())setAll() implementation allows cookie writesSymptom: Infinite redirects between login and dashboard
Cause: Middleware logic error or session not refreshing
Fix:
matcher config excludes static assetssupabase.auth.getUser() is awaitedrequest.nextUrl.clone() to avoid mutating original URLSymptom: Client Component doesn't receive database changes
Cause: Using server client instead of browser client
Fix:
createClient() from lib/supabase/client.ts (not server.ts).on('postgres_changes', ..., (payload) => { ... })
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
console.log('Subscribed!');
}
});
Manual Testing:
Sign Up:
Sign In:
Protected Route:
/dashboard → Redirect to /loginSession Refresh:
Multi-Tab Sync:
Required (both development and production):
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://<project-id>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
# Optional (for Server Actions with service role access)
SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
apps/web/apps/admin/development
Integrate Coinbase crypto payments into payment systems. Use when: (1) adding crypto payment support, (2) building onchain features, (3) implementing wallet functionality. Covers Coinbase Commerce (payment processor) vs CDP (developer platform), Server Wallets, Embedded Wallets, and multi-network support.
development
Add Apple Pay and Google Pay to Stripe checkout. Use when: (1) adding mobile wallet payments, (2) improving mobile conversion, (3) implementing one-tap checkout. Stripe Payment Request Button automatically detects device capabilities and shows Apple Pay (Safari/iOS) or Google Pay (Chrome/Android).
development
Master Vercel deployment for RidenDine web and admin Next.js apps. Use when: (1) deploying to production, (2) configuring environment variables, (3) setting up preview deployments, (4) debugging build failures, (5) configuring domains, (6) seeing "No Next.js version detected" error in Vercel builds, (7) setting up monorepo with separate projects on free tier. Key insight: Vercel monorepos require Root Directory configuration via dashboard (not vercel.json), GitHub integration auto-detects monorepo structure, free tier allows multiple projects.
development
Master Supabase Row Level Security (RLS) for RidenDine. Use when: (1) adding new tables, (2) modifying RLS policies, (3) debugging access control issues, (4) role-based data access. Key insight: All tables use RLS with role-based policies from profiles.role column.