Supabase and Next.js: The Full-Stack Setup I Actually Use
After building several production apps with this stack, here is the exact setup, patterns, and gotchas I have settled on for Supabase with Next.js App Router.
Supabase and Next.js App Router is the fastest way I know to go from idea to production. Here is how I actually set it up and use it.
The client setup
I keep a single Supabase client file. In App Router, the client is always constructed at request time (no module-level singleton), and I use the anon key only — never the service role key in client-accessible code.
import { createClient } from '@supabase/supabase-js';
function getSupabase() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !key) return null;
return createClient(url, key);
}RLS is not optional
Row Level Security is the thing that makes Supabase's anon key approach safe. Every table gets RLS enabled and explicit policies before any data goes in.
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Read-only for everyone
CREATE POLICY "Posts readable" ON posts
FOR SELECT USING (true);
-- Write only for authenticated users
CREATE POLICY "Users own posts" ON posts
FOR INSERT WITH CHECK (auth.uid() = user_id);Graceful error handling
Supabase queries return { data, error } — never throw by default. My service functions normalize this to predictable types:
export async function getPosts(): Promise<Post[]> {
const supabase = getSupabase();
if (!supabase) return []; // config not set — return empty
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching posts:', error);
return []; // fail gracefully
}
return data ?? [];
}Schema + seed workflow
I keep schema SQL and seed SQL as committed files and run them with the Supabase CLI:
npx supabase link --project-ref YOUR_REF
npx supabase db query --linked -f supabase/migrations/001_schema.sql
npx supabase db query --linked -f supabase/seed.sqlThis makes the database setup reproducible and reviewable. No clicking through the dashboard to reconstruct a schema.
The ISR pattern for public data
For public content (projects, blog metadata, pricing), I use ISR with a 1-hour revalidation. This gives static-site performance with automatic freshness.
export const revalidate = 3600; // 1 hour
export default async function ProjectsPage() {
const projects = await getProjects(); // Supabase call — runs at build + ISR
return <ProjectGrid projects={projects} />;
}The Supabase + Next.js stack is fast to ship and easy to maintain. The key is treating Supabase like any other typed API — consistent patterns, explicit error handling, and RLS always on.