skills/supabase/SKILL.md
Supabase integration patterns - Auth, RLS, Client configuration. Trigger: When working with Supabase (Auth, Database, Storage, Realtime) in Next.js. Use alongside drizzle skill for database operations. Client files go in: app/lib/supabase/
npx skillsauth add jovivaspo/base-agent-next-app supabaseInstall 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.
# .env.local
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Only for server-side operations (NOT exposed to client)
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
// lib/supabase/server.ts
import { createServerClient, type CookieOptions } 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: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options })
} catch (error) {
// Handle cookie errors
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options })
} catch (error) {
// Handle cookie errors
}
},
},
}
)
}
// 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!
)
}
// app/actions/auth.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function signUp(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
const supabase = await createClient()
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
})
if (error) {
return { error: error.message }
}
revalidatePath('/', 'layout')
return { success: true }
}
// app/actions/auth.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function signIn(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
return { error: error.message }
}
revalidatePath('/', 'layout')
return { success: true }
}
// app/actions/auth.ts
'use server'
import { createClient } from '@/lib/supabase/server'
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
revalidatePath('/', 'layout')
}
// lib/supabase/user.ts
import { createClient } from '@/lib/supabase/server'
export async function getUser() {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) {
return null
}
return user
}
// middleware.ts
import { createServerClient, type CookieOptions } 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: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
supabaseResponse = NextResponse.next({
request: { headers: request.headers },
})
supabaseResponse.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
supabaseResponse = NextResponse.next({
request: { headers: request.headers },
})
supabaseResponse.cookies.set({ name, value: '', ...options })
},
},
}
)
// Refresh session if expired
const { data: { user } } = await supabase.auth.getUser()
// Protect routes
if (!user && !request.nextUrl.pathname.startsWith('/login')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
// app/auth/callback/route.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (code) {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
const response = NextResponse.next({ request })
response.cookies.set({ name, value, ...options })
return response
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
const response = NextResponse.next({ request })
response.cookies.set({ name, value: '', ...options })
return response
},
},
}
)
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
// Return the user to an error page
return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}
RLS is configured in Supabase Dashboard or via migrations:
-- Enable RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Anyone can read profiles
CREATE POLICY "Public profiles are viewable by everyone"
ON profiles FOR SELECT
USING (true);
-- Users can only insert their own profile
CREATE POLICY "Users can insert their own profile"
ON profiles FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can only update their own profile
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = user_id);
-- Only authenticated users can see their own data
CREATE POLICY "Authenticated users can view own data"
ON my_table FOR SELECT
USING (auth.role() = 'authenticated');
-- Enable RLS on storage
CREATE POLICY "Users can upload their own files"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'user-uploads' AND auth.uid() = owner);
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function ProtectedPage() {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) {
redirect('/login')
}
return (
<div>
<h1>Welcome, {user.email}</h1>
</div>
)
}
// app/components/UserMenu.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
export function UserMenu() {
const [user, setUser] = useState<any>(null)
const router = useRouter()
const supabase = createClient()
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
setUser(user)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null)
})
return () => subscription.unsubscribe()
}, [supabase])
const handleSignOut = async () => {
await supabase.auth.signOut()
router.refresh()
router.push('/login')
}
if (!user) {
return <a href="/login">Sign In</a>
}
return (
<div>
<span>{user.email}</span>
<button onClick={handleSignOut}>Sign Out</button>
</div>
)
}
// ✅ ALWAYS: Use server client for server operations
const supabase = await createClient() // Server
// ✅ ALWAYS: Use browser client for client components
const supabase = createClient() // Client
// ✅ ALWAYS: Validate user before database operations
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
// ✅ ALWAYS: Use RLS policies (never rely on app logic alone)
// ✅ NEVER: Expose SERVICE_ROLE_KEY to client
// Only use it in server-side code, never in client components
// ✅ NEVER: Trust client-side auth state alone
// Always validate on server side before sensitive operations
tools
Zustand 5 state management patterns. Trigger: When implementing client-side state with Zustand (stores, selectors, persist middleware, slices).
databases
Zod 4 schema validation patterns. Trigger: When creating or updating Zod v4 schemas for validation/parsing (forms, request payloads, adapters), including v3 -> v4 migration patterns.
development
TypeScript strict patterns and best practices. Trigger: When implementing or refactoring TypeScript in .ts/.tsx (types, interfaces, generics, const maps, type guards, removing any, tightening unknown).
development
Test-Driven Development workflow using Vitest + React Testing Library. Trigger: ALWAYS when implementing features, fixing bugs, or refactoring in Next.js. This is a MANDATORY workflow for all TypeScript/React code.