Supabase Integration Patterns
Setup
Installation
bash
npm install @supabase/supabase-js @supabase/ssr
Environment Variables
env
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
Client Setup (Next.js App Router)
ts
// 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!
)
}
ts
// lib/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
export function createClient() {
const cookieStore = 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 setting in Server Components
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options })
} catch (error) {
// Handle cookie removal in Server Components
}
},
},
}
)
}
ts
// lib/supabase/middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
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 })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value: '', ...options })
},
},
}
)
await supabase.auth.getUser()
return response
}
ts
// middleware.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Authentication
Sign Up
tsx
// app/signup/page.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
export default function SignUpPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/verify-email')
}
return (
<form onSubmit={handleSignUp}>
{error && <div className="text-red-500">{error}</div>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
minLength={8}
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Signing up...' : 'Sign Up'}
</button>
</form>
)
}
Sign In
tsx
// app/login/page.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
router.refresh()
}
return (
<form onSubmit={handleLogin}>
{error && <div className="text-red-500">{error}</div>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
)
}
OAuth Login
tsx
// components/OAuthButtons.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
export function OAuthButtons() {
const supabase = createClient()
const handleOAuthLogin = async (provider: 'google' | 'github') => {
await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
}
return (
<div className="space-y-3">
<button
onClick={() => handleOAuthLogin('google')}
className="w-full flex items-center justify-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
>
<GoogleIcon />
Continue with Google
</button>
<button
onClick={() => handleOAuthLogin('github')}
className="w-full flex items-center justify-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
>
<GitHubIcon />
Continue with GitHub
</button>
</div>
)
}
Auth Callback Handler
tsx
// 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 = createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/auth/error`)
}
Sign Out
tsx
// components/SignOutButton.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
export function SignOutButton() {
const router = useRouter()
const supabase = createClient()
const handleSignOut = async () => {
await supabase.auth.signOut()
router.push('/')
router.refresh()
}
return (
<button onClick={handleSignOut}>
Sign Out
</button>
)
}
Get Current User (Server)
tsx
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) {
redirect('/login')
}
return (
<div>
<h1>Welcome, {user.email}</h1>
</div>
)
}
Database Operations
TypeScript Types Generation
bash
# Generate types from your Supabase schema npx supabase gen types typescript --project-id your-project-id > types/supabase.ts
Typed Client
ts
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
import { Database } from '@/types/supabase'
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
Basic Queries
tsx
// Fetch all records
const { data, error } = await supabase
.from('posts')
.select('*')
// Fetch with filters
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('status', 'published')
.order('created_at', { ascending: false })
.limit(10)
// Fetch with relations
const { data, error } = await supabase
.from('posts')
.select(`
*,
author:profiles(id, name, avatar_url),
comments(id, content, created_at)
`)
.eq('id', postId)
.single()
// Pagination
const { data, error, count } = await supabase
.from('posts')
.select('*', { count: 'exact' })
.range(0, 9) // First 10 items
// Search
const { data, error } = await supabase
.from('posts')
.select('*')
.ilike('title', `%${searchTerm}%`)
Insert Records
tsx
// Single insert
const { data, error } = await supabase
.from('posts')
.insert({
title: 'My Post',
content: 'Post content...',
user_id: user.id,
})
.select()
.single()
// Bulk insert
const { data, error } = await supabase
.from('posts')
.insert([
{ title: 'Post 1', content: '...' },
{ title: 'Post 2', content: '...' },
])
.select()
Update Records
tsx
const { data, error } = await supabase
.from('posts')
.update({ title: 'Updated Title' })
.eq('id', postId)
.select()
.single()
Delete Records
tsx
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId)
Server Component Data Fetching
tsx
// app/posts/page.tsx
import { createClient } from '@/lib/supabase/server'
export default async function PostsPage() {
const supabase = createClient()
const { data: posts, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return <div>Error loading posts</div>
}
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
Real-time Subscriptions
tsx
// hooks/useRealtimePosts.ts
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { Database } from '@/types/supabase'
type Post = Database['public']['Tables']['posts']['Row']
export function useRealtimePosts() {
const [posts, setPosts] = useState<Post[]>([])
const supabase = createClient()
useEffect(() => {
// Fetch initial data
const fetchPosts = async () => {
const { data } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (data) setPosts(data)
}
fetchPosts()
// Subscribe to changes
const channel = supabase
.channel('posts-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'posts',
},
(payload) => {
if (payload.eventType === 'INSERT') {
setPosts((prev) => [payload.new as Post, ...prev])
} else if (payload.eventType === 'UPDATE') {
setPosts((prev) =>
prev.map((post) =>
post.id === payload.new.id ? (payload.new as Post) : post
)
)
} else if (payload.eventType === 'DELETE') {
setPosts((prev) =>
prev.filter((post) => post.id !== payload.old.id)
)
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
return posts
}
Storage
Upload File
tsx
// components/FileUpload.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function FileUpload({ bucket, onUpload }: { bucket: string; onUpload: (url: string) => void }) {
const [uploading, setUploading] = useState(false)
const supabase = createClient()
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}.${fileExt}`
const filePath = `${fileName}`
const { error: uploadError } = await supabase.storage
.from(bucket)
.upload(filePath, file)
if (uploadError) {
console.error('Upload error:', uploadError)
setUploading(false)
return
}
const { data } = supabase.storage.from(bucket).getPublicUrl(filePath)
onUpload(data.publicUrl)
setUploading(false)
}
return (
<input
type="file"
onChange={handleUpload}
disabled={uploading}
accept="image/*"
/>
)
}
Download File
tsx
const { data, error } = await supabase.storage
.from('bucket')
.download('path/to/file.pdf')
if (data) {
const url = URL.createObjectURL(data)
// Use url for download or display
}
Delete File
tsx
const { error } = await supabase.storage
.from('bucket')
.remove(['path/to/file.pdf'])
Row Level Security (RLS) Patterns
Basic RLS Policies
sql
-- Enable RLS ALTER TABLE posts ENABLE ROW LEVEL SECURITY; -- Users can read all published posts CREATE POLICY "Public posts are viewable by everyone" ON posts FOR SELECT USING (status = 'published'); -- Users can only insert their own posts CREATE POLICY "Users can insert their own posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id); -- Users can only update their own posts CREATE POLICY "Users can update their own posts" ON posts FOR UPDATE USING (auth.uid() = user_id); -- Users can only delete their own posts CREATE POLICY "Users can delete their own posts" ON posts FOR DELETE USING (auth.uid() = user_id);
Profile Table Pattern
sql
-- Create profiles table CREATE TABLE profiles ( id UUID REFERENCES auth.users PRIMARY KEY, name TEXT, avatar_url TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Enable RLS ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; -- Everyone can view profiles CREATE POLICY "Profiles are viewable by everyone" ON profiles FOR SELECT USING (true); -- Users can only update their own profile CREATE POLICY "Users can update their own profile" ON profiles FOR UPDATE USING (auth.uid() = id); -- Auto-create profile on signup CREATE FUNCTION public.handle_new_user() RETURNS TRIGGER AS $$ BEGIN INSERT INTO public.profiles (id) VALUES (NEW.id); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
Error Handling
tsx
// lib/supabase/helpers.ts
import { PostgrestError } from '@supabase/supabase-js'
export function handleSupabaseError(error: PostgrestError | null): string {
if (!error) return ''
// Map common errors to user-friendly messages
const errorMessages: Record<string, string> = {
'23505': 'This record already exists.',
'23503': 'Referenced record not found.',
'42501': 'You do not have permission to perform this action.',
'PGRST116': 'Record not found.',
}
return errorMessages[error.code] || error.message
}
// Usage
const { data, error } = await supabase.from('posts').insert({ title: 'Test' })
if (error) {
const message = handleSupabaseError(error)
// Show message to user
}