Supabase + Next.js Skill
Load with: base.md + supabase.md + typescript.md
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=
Server-side only
SUPABASE_SERVICE_ROLE_KEY=
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 (
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
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 (
); }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 (
Your Posts
{posts.map((post) => ({post.title}
{post.content}
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
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 ( ); }
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 (
-
{posts.map((post) => (
- {post.title} ))}
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 (
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 (
); }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 cookies() synchronously - Must await in Next.js 15+ Service key in client - Never expose, server-only Missing revalidatePath - Always revalidate after mutations Not handling auth errors - Show user-friendly messages