Why Supabase Is the Best Backend for Next.js
Supabase is an open-source Firebase alternative built on PostgreSQL. It gives you a hosted Postgres database, auth with row-level security, file storage, and realtime subscriptions — all behind a single TypeScript client. For Next.js applications, it is the fastest path from zero to a production backend without operating your own database or auth infrastructure.
The combination of Supabase with Next.js 15's App Router and Server Actions is particularly powerful: you get server-side data access without exposing service keys to the client, and Server Actions handle mutations without writing API routes.
Project Setup
npm install @supabase/supabase-js @supabase/ssr
# .env.local
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 # server-only
Create two Supabase clients: a browser client (using the anon key, subject to Row Level Security) and a server client (using the service role key, for admin operations in Server Actions and Route Handlers).
// lib/supabase/client.ts — browser component usage
import { createBrowserClient } from '@supabase/ssr'
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// lib/supabase/server.ts — server component / server action usage
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export const createClient = async () => {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => cookieStore.getAll(), setAll: (c) => c.forEach(({ name, value, options }) => cookieStore.set(name, value, options)) } }
)
}
Row Level Security: The Right Way
Enable RLS on every table and write policies that enforce access rules in the database — not just in your application code. This means even if a bug in your code sends the wrong user ID, the database rejects the query.
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Users can only read their own posts
CREATE POLICY "users_read_own_posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);
-- Users can only insert posts as themselves
CREATE POLICY "users_insert_own_posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Allow public read for published posts
CREATE POLICY "public_read_published"
ON posts FOR SELECT
USING (published = true);
Server Actions for Mutations
Use Server Actions for form submissions and data mutations. They run on the server, have access to the service role key, and can revalidate cached data after mutation.
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
export async function createPost(formData: FormData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Unauthorized')
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('/dashboard/posts')
}
Realtime Subscriptions
Supabase streams database changes over WebSockets using PostgreSQL's logical replication. Subscribe to table changes in client components for live-updating UIs.
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function LiveComments({ postId }: { postId: string }) {
const [comments, setComments] = useState([])
const supabase = createClient()
useEffect(() => {
const channel = supabase
.channel('comments')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'comments',
filter: `post_id=eq.${postId}`,
}, (payload) => {
setComments(prev => [...prev, payload.new])
})
.subscribe()
return () => { supabase.removeChannel(channel) }
}, [postId])
return comments.map(c => )
}
File Storage
Supabase Storage provides S3-compatible file storage with access control tied to auth. Use it for user avatars, document uploads, and any user-generated content.
const supabase = await createClient()
// Upload
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${user.id}/avatar.png`, file, {
upsert: true,
contentType: 'image/png',
})
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(`${user.id}/avatar.png`)
Set storage bucket policies to match your RLS strategy — public buckets for publicly accessible content, private buckets for user-owned files with signed URLs for time-limited access.