install
source · Clone the upstream repo
git clone https://github.com/alinaqi/claude-bootstrap
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/alinaqi/claude-bootstrap "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/supabase-nextjs" ~/.claude/skills/alinaqi-claude-bootstrap-supabase-nextjs && rm -rf "$T"
manifest:
skills/supabase-nextjs/SKILL.mdsource content
Supabase + Next.js Skill
Next.js App Router patterns with Supabase Auth and Drizzle ORM.
Sources: Supabase Next.js Guide | Drizzle + Supabase
Core Principle
Drizzle for queries, Supabase for auth/storage, server components by default.
Use Drizzle ORM for type-safe database access. Use Supabase client for auth, storage, and realtime. Prefer server components; use client components only when needed.
Project Structure
project/ ├── src/ │ ├── app/ │ │ ├── (auth)/ │ │ │ ├── login/page.tsx │ │ │ ├── signup/page.tsx │ │ │ └── callback/route.ts │ │ ├── (dashboard)/ │ │ │ └── page.tsx │ │ ├── api/ │ │ │ └── [...]/route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components/ │ │ ├── auth/ │ │ └── ui/ │ ├── db/ │ │ ├── index.ts # Drizzle client │ │ ├── schema.ts # Schema definitions │ │ └── queries/ # Query functions │ ├── lib/ │ │ ├── supabase/ │ │ │ ├── client.ts # Browser client │ │ │ ├── server.ts # Server client │ │ │ └── middleware.ts # Auth middleware helper │ │ └── auth.ts # Auth helpers │ └── middleware.ts # Next.js middleware ├── supabase/ │ ├── migrations/ │ └── config.toml ├── drizzle.config.ts └── .env.local
Setup
Install Dependencies
npm install @supabase/supabase-js @supabase/ssr drizzle-orm postgres npm install -D drizzle-kit
Environment Variables
# .env.local NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY=<from supabase start> # Server-side only SUPABASE_SERVICE_ROLE_KEY=<from supabase start> DATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres
Drizzle Setup
drizzle.config.ts
import { defineConfig } from 'drizzle-kit'; export default defineConfig({ schema: './src/db/schema.ts', out: './supabase/migrations', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL!, }, schemaFilter: ['public'], });
src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from './schema'; const client = postgres(process.env.DATABASE_URL!, { prepare: false, // Required for Supabase connection pooling }); export const db = drizzle(client, { schema });
src/db/schema.ts
import { pgTable, uuid, text, timestamp, boolean, } from 'drizzle-orm/pg-core'; export const profiles = pgTable('profiles', { id: uuid('id').primaryKey(), // References auth.users email: text('email').notNull(), name: text('name'), avatarUrl: text('avatar_url'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); export const posts = pgTable('posts', { id: uuid('id').primaryKey().defaultRandom(), authorId: uuid('author_id').references(() => profiles.id).notNull(), title: text('title').notNull(), content: text('content'), published: boolean('published').default(false), createdAt: timestamp('created_at').defaultNow().notNull(), }); // Type exports export type Profile = typeof profiles.$inferSelect; export type NewProfile = typeof profiles.$inferInsert; export type Post = typeof posts.$inferSelect; export type NewPost = typeof posts.$inferInsert;
Supabase Clients
src/lib/supabase/client.ts (Browser)
import { createBrowserClient } from '@supabase/ssr'; export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); }
src/lib/supabase/server.ts (Server Components/Actions)
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 Server Component - ignore } }, }, } ); }
src/lib/supabase/middleware.ts (For Middleware)
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 const { data: { user } } = await supabase.auth.getUser(); return { supabaseResponse, user }; }
Middleware
src/middleware.ts
import { type NextRequest, NextResponse } from 'next/server'; import { updateSession } from '@/lib/supabase/middleware'; const publicRoutes = ['/', '/login', '/signup', '/auth/callback']; export async function middleware(request: NextRequest) { const { supabaseResponse, user } = await updateSession(request); const isPublicRoute = publicRoutes.some(route => request.nextUrl.pathname.startsWith(route) ); // Redirect unauthenticated users to login if (!user && !isPublicRoute) { const url = request.nextUrl.clone(); url.pathname = '/login'; url.searchParams.set('redirectTo', request.nextUrl.pathname); return NextResponse.redirect(url); } // Redirect authenticated users away from auth pages if (user && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup')) { return NextResponse.redirect(new URL('/dashboard', request.url)); } return supabaseResponse; } export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], };
Auth Helpers
src/lib/auth.ts
import { redirect } from 'next/navigation'; import { createClient } from '@/lib/supabase/server'; export async function getUser() { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); return user; } export async function requireAuth() { const user = await getUser(); if (!user) { redirect('/login'); } return user; } export async function requireGuest() { const user = await getUser(); if (user) { redirect('/dashboard'); } }
Auth Pages
src/app/(auth)/login/page.tsx
import { requireGuest } from '@/lib/auth'; import { LoginForm } from '@/components/auth/login-form'; export default async function LoginPage() { await requireGuest(); return ( <div className="flex min-h-screen items-center justify-center"> <LoginForm /> </div> ); }
src/components/auth/login-form.tsx
'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; export function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false); const router = useRouter(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); const supabase = createClient(); const { error } = await supabase.auth.signInWithPassword({ email, password, }); if (error) { setError(error.message); setLoading(false); return; } router.push('/dashboard'); router.refresh(); }; return ( <form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm"> <div> <label htmlFor="email">Email</label> <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div> <label htmlFor="password">Password</label> <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> {error && <p className="text-red-500">{error}</p>} <button type="submit" disabled={loading}> {loading ? 'Signing in...' : 'Sign In'} </button> </form> ); }
src/app/(auth)/callback/route.ts
import { createClient } from '@/lib/supabase/server'; import { NextResponse } from 'next/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_error`); }
Server Actions
src/app/actions/posts.ts
'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { db } from '@/db'; import { posts, NewPost } from '@/db/schema'; import { requireAuth } from '@/lib/auth'; import { eq } from 'drizzle-orm'; export async function createPost(formData: FormData) { const user = await requireAuth(); const title = formData.get('title') as string; const content = formData.get('content') as string; const [post] = await db.insert(posts).values({ authorId: user.id, title, content, }).returning(); revalidatePath('/dashboard'); redirect(`/posts/${post.id}`); } export async function updatePost(id: string, formData: FormData) { const user = await requireAuth(); const title = formData.get('title') as string; const content = formData.get('content') as string; await db.update(posts) .set({ title, content }) .where(eq(posts.id, id)); revalidatePath(`/posts/${id}`); } export async function deletePost(id: string) { const user = await requireAuth(); await db.delete(posts).where(eq(posts.id, id)); revalidatePath('/dashboard'); redirect('/dashboard'); }
Data Fetching
src/db/queries/posts.ts
import { db } from '@/db'; import { posts, profiles } from '@/db/schema'; import { eq, desc, and } from 'drizzle-orm'; export async function getPublishedPosts(limit = 10) { return db .select({ id: posts.id, title: posts.title, content: posts.content, author: profiles.name, createdAt: posts.createdAt, }) .from(posts) .innerJoin(profiles, eq(posts.authorId, profiles.id)) .where(eq(posts.published, true)) .orderBy(desc(posts.createdAt)) .limit(limit); } export async function getUserPosts(userId: string) { return db .select() .from(posts) .where(eq(posts.authorId, userId)) .orderBy(desc(posts.createdAt)); } export async function getPostById(id: string) { const [post] = await db .select() .from(posts) .where(eq(posts.id, id)) .limit(1); return post ?? null; }
In Server Components
// src/app/dashboard/page.tsx import { requireAuth } from '@/lib/auth'; import { getUserPosts } from '@/db/queries/posts'; export default async function DashboardPage() { const user = await requireAuth(); const posts = await getUserPosts(user.id); return ( <div> <h1>Your Posts</h1> {posts.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </article> ))} </div> ); }
Storage
Upload Component
'use client'; import { useState } from 'react'; import { createClient } from '@/lib/supabase/client'; export function AvatarUpload({ userId }: { userId: string }) { const [uploading, setUploading] = useState(false); const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; setUploading(true); const supabase = createClient(); const fileExt = file.name.split('.').pop(); const filePath = `${userId}/avatar.${fileExt}`; const { error } = await supabase.storage .from('avatars') .upload(filePath, file, { upsert: true }); if (error) { console.error('Upload error:', error); } setUploading(false); }; return ( <input type="file" accept="image/*" onChange={handleUpload} disabled={uploading} /> ); }
Get Public URL
import { createClient } from '@/lib/supabase/server'; export async function getAvatarUrl(userId: string) { const supabase = await createClient(); const { data } = supabase.storage .from('avatars') .getPublicUrl(`${userId}/avatar.png`); return data.publicUrl; }
Realtime
Client Component with Subscription
'use client'; import { useEffect, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; import { Post } from '@/db/schema'; export function RealtimePosts({ initialPosts }: { initialPosts: Post[] }) { const [posts, setPosts] = useState(initialPosts); useEffect(() => { const supabase = createClient(); const channel = supabase .channel('posts') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'posts' }, (payload) => { if (payload.eventType === 'INSERT') { setPosts((prev) => [payload.new as Post, ...prev]); } else if (payload.eventType === 'DELETE') { setPosts((prev) => prev.filter((p) => p.id !== payload.old.id)); } else if (payload.eventType === 'UPDATE') { setPosts((prev) => prev.map((p) => (p.id === payload.new.id ? payload.new as Post : p)) ); } } ) .subscribe(); return () => { supabase.removeChannel(channel); }; }, []); return ( <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }
OAuth Providers
src/components/auth/oauth-buttons.tsx
'use client'; import { createClient } from '@/lib/supabase/client'; export function OAuthButtons() { const handleOAuth = async (provider: 'google' | 'github') => { const supabase = createClient(); await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${window.location.origin}/auth/callback`, }, }); }; return ( <div className="space-y-2"> <button onClick={() => handleOAuth('google')}> Continue with Google </button> <button onClick={() => handleOAuth('github')}> Continue with GitHub </button> </div> ); }
Sign Out
Server Action
// src/app/actions/auth.ts 'use server'; import { redirect } from 'next/navigation'; import { createClient } from '@/lib/supabase/server'; export async function signOut() { const supabase = await createClient(); await supabase.auth.signOut(); redirect('/login'); }
Sign Out Button
'use client'; import { signOut } from '@/app/actions/auth'; export function SignOutButton() { return ( <form action={signOut}> <button type="submit">Sign Out</button> </form> ); }
Anti-Patterns
- Using Supabase client for DB queries - Use Drizzle for type-safety
- Fetching in client components - Prefer server components
- Not using middleware for auth - Session refresh is critical
- Calling
synchronously - Must await in Next.js 15+cookies() - Service key in client - Never expose, server-only
- Missing revalidatePath - Always revalidate after mutations
- Not handling auth errors - Show user-friendly messages