Supabase with Next.js: Complete Tutorial for 2025
Kamis, 16 Jan 2025
Supabase is an open-source Firebase alternative that provides a PostgreSQL database, authentication, real-time subscriptions, and storage. Combined with Next.js, it’s a powerful stack for building full-stack applications.
Project Setup
Create Next.js App
npx create-next-app@latest my-supabase-app --typescript --tailwind --app
cd my-supabase-app
Install Supabase
npm install @supabase/supabase-js @supabase/ssr
Environment Variables
Create .env.local:
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Get these values from your Supabase project dashboard under Settings > API.
Supabase Client Configuration
Browser Client
Create 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!
)
}
Server Client
Create 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: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Handle server component cookie setting
}
},
},
}
)
}
Middleware for Auth
Create middleware.ts in your project root:
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)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
// Protect routes
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
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)$).*)',
],
}
Authentication
Sign Up with Email
// app/auth/signup/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
export default function SignUpPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
const supabase = createClient()
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault()
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${location.origin}/auth/callback`,
},
})
if (error) {
setMessage(error.message)
} else {
setMessage('Check your email for the confirmation link!')
}
}
return (
<form onSubmit={handleSignUp} className="space-y-4 max-w-md mx-auto p-8">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-2 border rounded"
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-2 border rounded"
required
/>
<button type="submit" className="w-full bg-blue-500 text-white p-2 rounded">
Sign Up
</button>
{message && <p className="text-center">{message}</p>}
</form>
)
}
Sign In
// app/auth/login/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
const supabase = createClient()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
} else {
router.push('/dashboard')
router.refresh()
}
}
return (
<form onSubmit={handleLogin} className="space-y-4 max-w-md mx-auto p-8">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-2 border rounded"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-2 border rounded"
/>
<button type="submit" className="w-full bg-blue-500 text-white p-2 rounded">
Sign In
</button>
{error && <p className="text-red-500 text-center">{error}</p>}
</form>
)
}
OAuth (Google, GitHub)
'use client'
import { createClient } from '@/lib/supabase/client'
export function OAuthButtons() {
const supabase = createClient()
const signInWithGoogle = async () => {
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${location.origin}/auth/callback`,
},
})
}
const signInWithGitHub = async () => {
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${location.origin}/auth/callback`,
},
})
}
return (
<div className="space-y-2">
<button onClick={signInWithGoogle} className="w-full p-2 border rounded">
Continue with Google
</button>
<button onClick={signInWithGitHub} className="w-full p-2 border rounded">
Continue with GitHub
</button>
</div>
)
}
Auth Callback Route
Create 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}/auth/error`)
}
Sign Out
'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} className="text-red-500">
Sign Out
</button>
)
}
Database Operations
TypeScript Types Generation
npx supabase gen types typescript --project-id your-project-id > types/supabase.ts
Typed Client
import { createClient } from '@/lib/supabase/server'
import { Database } from '@/types/supabase'
// Types are automatically inferred
const supabase = await createClient()
const { data: posts } = await supabase.from('posts').select('*')
// posts is typed as Database['public']['Tables']['posts']['Row'][]
CRUD Operations
Create
// Server Action
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
throw new Error('Not authenticated')
}
const { error } = await supabase.from('posts').insert({
title: formData.get('title') as string,
content: formData.get('content') as string,
user_id: user.id,
})
if (error) throw error
revalidatePath('/posts')
}
Read
// Server Component
import { createClient } from '@/lib/supabase/server'
export default async function PostsPage() {
const supabase = await createClient()
const { data: posts, error } = await supabase
.from('posts')
.select(`
id,
title,
content,
created_at,
author:users(name, avatar_url)
`)
.order('created_at', { ascending: false })
.limit(10)
if (error) {
console.error(error)
return <div>Error loading posts</div>
}
return (
<div className="space-y-4">
{posts?.map((post) => (
<article key={post.id} className="p-4 border rounded">
<h2 className="text-xl font-bold">{post.title}</h2>
<p>{post.content}</p>
<span className="text-gray-500">By {post.author?.name}</span>
</article>
))}
</div>
)
}
Update
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function updatePost(id: string, formData: FormData) {
const supabase = await createClient()
const { error } = await supabase
.from('posts')
.update({
title: formData.get('title') as string,
content: formData.get('content') as string,
updated_at: new Date().toISOString(),
})
.eq('id', id)
if (error) throw error
revalidatePath('/posts')
revalidatePath(`/posts/${id}`)
}
Delete
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function deletePost(id: string) {
const supabase = await createClient()
const { error } = await supabase
.from('posts')
.delete()
.eq('id', id)
if (error) throw error
revalidatePath('/posts')
}
Row Level Security (RLS)
Enable RLS in your Supabase dashboard and create policies:
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Policy: Anyone can read posts
CREATE POLICY "Public posts are viewable by everyone"
ON posts FOR SELECT
USING (published = true);
-- Policy: 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);
-- Policy: Users can only update their own posts
CREATE POLICY "Users can update their own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id);
-- Policy: Users can only delete their own posts
CREATE POLICY "Users can delete their own posts"
ON posts FOR DELETE
USING (auth.uid() = user_id);
Real-time Subscriptions
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
import { RealtimePostgresChangesPayload } from '@supabase/supabase-js'
interface Message {
id: string
content: string
user_id: string
created_at: string
}
export function ChatMessages({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([])
const supabase = createClient()
useEffect(() => {
// Fetch initial messages
const fetchMessages = async () => {
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
fetchMessages()
// Subscribe to real-time changes
const channel = supabase
.channel(`room:${roomId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`,
},
(payload: RealtimePostgresChangesPayload<Message>) => {
if (payload.new) {
setMessages((prev) => [...prev, payload.new as Message])
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [roomId, supabase])
return (
<div className="space-y-2">
{messages.map((message) => (
<div key={message.id} className="p-2 bg-gray-100 rounded">
{message.content}
</div>
))}
</div>
)
}
Storage
Upload Files
'use client'
import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
export function FileUpload() {
const [uploading, setUploading] = useState(false)
const supabase = createClient()
const uploadFile = 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 = `uploads/${fileName}`
const { error } = await supabase.storage
.from('files')
.upload(filePath, file)
if (error) {
console.error('Error uploading:', error)
} else {
// Get public URL
const { data } = supabase.storage
.from('files')
.getPublicUrl(filePath)
console.log('File URL:', data.publicUrl)
}
setUploading(false)
}
return (
<input
type="file"
onChange={uploadFile}
disabled={uploading}
/>
)
}
Download Files
const { data, error } = await supabase.storage
.from('files')
.download('uploads/file.pdf')
if (data) {
const url = URL.createObjectURL(data)
// Use url for download or display
}
Delete Files
const { error } = await supabase.storage
.from('files')
.remove(['uploads/file1.pdf', 'uploads/file2.pdf'])
Best Practices
1. Use Server Components for Data Fetching
// Good: Server Component
export default async function Page() {
const supabase = await createClient()
const { data } = await supabase.from('posts').select('*')
return <PostList posts={data} />
}
2. Use Server Actions for Mutations
// Good: Server Action
'use server'
export async function createPost(formData: FormData) {
const supabase = await createClient()
// ... mutation logic
revalidatePath('/posts')
}
3. Type Your Database
Always generate and use TypeScript types:
import { Database } from '@/types/supabase'
type Post = Database['public']['Tables']['posts']['Row']
type PostInsert = Database['public']['Tables']['posts']['Insert']
4. Handle Errors Gracefully
const { data, error } = await supabase.from('posts').select('*')
if (error) {
// Log for debugging
console.error('Supabase error:', error)
// Show user-friendly message
return <ErrorMessage message="Failed to load posts" />
}
5. Use RLS for Security
Never rely on client-side validation alone. Always implement Row Level Security policies in Supabase.
Conclusion
Supabase + Next.js is a powerful combination for building full-stack applications. Key takeaways:
- Use separate clients for browser and server
- Implement middleware for auth protection
- Leverage Server Components and Server Actions
- Always enable and configure RLS
- Generate TypeScript types for type safety
Start building your next project with this stack!