#Introduction
The combination of Next.js 15 and Supabase has rapidly become one of the most productive stacks for building full-stack web applications. You get React Server Components, streaming SSR, and the App Router on the frontend paired with a fully managed PostgreSQL database, built-in auth, real-time subscriptions, and file storage on the backend.
In this guide we'll build a complete application from scratch, covering every layer of the stack. By the end, you'll have a production-ready app deployed to Vercel.
#Prerequisites
Node.js 18+
A Supabase account (free tier works)
A Vercel account for deployment
Familiarity with React and TypeScript basics
#1. Project Setup
Start by scaffolding a new Next.js project with the App Router and TypeScript:
1npx create-next-app@latest my-app \
2 --typescript \
3 --tailwind \
4 --eslint \
5 --app \
6 --src-dir=false
7cd my-appThen install the Supabase client libraries:
1npm install @supabase/supabase-js @supabase/ssr#2. Supabase Project Configuration
Create a new project in the Supabase Dashboard. Once created, grab your Project URL and anon key from Settings → API and add them to .env.local:
1NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
2NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key#3. Database Schema Design
Good schema design is the foundation of any robust app. Here's a simple schema for a task management app run this SQL in the Supabase SQL Editor:
1-- Profiles table (extends auth.users)
2create table profiles (
3 id uuid references auth.users on delete cascade primary key,
4 username text unique not null,
5 full_name text,
6 avatar_url text,
7 updated_at timestamptz default now()
8);
9
10-- Tasks table
11create table tasks (
12 id uuid default gen_random_uuid() primary key,
13 user_id uuid references profiles(id) on delete cascade not null,
14 title text not null,
15 description text,
16 completed boolean default false,
17 created_at timestamptz default now(),
18 updated_at timestamptz default now()
19);
20
21-- Indexes
22create index tasks_user_id_idx on tasks(user_id);
23create index tasks_created_at_idx on tasks(created_at desc);#4. Row-Level Security (RLS)
RLS is one of Supabase's killer features. It lets you enforce authorization rules directly in the database, so even if someone bypasses your API, they can't access data they shouldn't see.
1-- Enable RLS
2alter table profiles enable row level security;
3alter table tasks enable row level security;
4
5-- Profiles: users can only read/update their own profile
6create policy "Users can view own profile"
7 on profiles for select using (auth.uid() = id);
8
9create policy "Users can update own profile"
10 on profiles for update using (auth.uid() = id);
11
12-- Tasks: users can only CRUD their own tasks
13create policy "Users can view own tasks"
14 on tasks for select using (auth.uid() = user_id);
15
16create policy "Users can insert own tasks"
17 on tasks for insert with check (auth.uid() = user_id);
18
19create policy "Users can update own tasks"
20 on tasks for update using (auth.uid() = user_id);
21
22create policy "Users can delete own tasks"
23 on tasks for delete using (auth.uid() = user_id);#5. Supabase Client Setup
With @supabase/ssr, you get separate client helpers for Server Components, Client Components, and Route Handlers:
1// lib/supabase/server.ts
2import { createServerClient } from '@supabase/ssr'
3import { cookies } from 'next/headers'
4
5export async function createClient() {
6 const cookieStore = await cookies()
7 return createServerClient(
8 process.env.NEXT_PUBLIC_SUPABASE_URL!,
9 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
10 {
11 cookies: {
12 getAll() { return cookieStore.getAll() },
13 setAll(cookiesToSet) {
14 cookiesToSet.forEach(({ name, value, options }) =>
15 cookieStore.set(name, value, options)
16 )
17 },
18 },
19 }
20 )
21}#6. Authentication with Server Components
Next.js Server Components let you check auth state server-side before rendering no flash of unauthenticated content.
1// app/dashboard/page.tsx
2import { redirect } from 'next/navigation'
3import { createClient } from '@/lib/supabase/server'
4
5export default async function DashboardPage() {
6 const supabase = await createClient()
7 const { data: { user } } = await supabase.auth.getUser()
8
9 if (!user) redirect('/login')
10
11 const { data: tasks } = await supabase
12 .from('tasks')
13 .select('*')
14 .order('created_at', { ascending: false })
15
16 return (
17 <main>
18 <h1>Welcome back, {user.email}</h1>
19 <TaskList tasks={tasks ?? []} />
20 </main>
21 )
22}#7. Real-Time Subscriptions
Supabase lets you subscribe to database changes in real time using Postgres replication. Here's a client component that listens for new tasks:
1'use client'
2import { useEffect, useState } from 'react'
3import { createClient } from '@/lib/supabase/client'
4import type { Task } from '@/lib/types'
5
6export function RealtimeTaskList({ initialTasks }: { initialTasks: Task[] }) {
7 const [tasks, setTasks] = useState(initialTasks)
8 const supabase = createClient()
9
10 useEffect(() => {
11 const channel = supabase
12 .channel('tasks-channel')
13 .on('postgres_changes', {
14 event: '*',
15 schema: 'public',
16 table: 'tasks',
17 }, (payload) => {
18 if (payload.eventType === 'INSERT') {
19 setTasks(prev => [payload.new as Task, ...prev])
20 } else if (payload.eventType === 'DELETE') {
21 setTasks(prev => prev.filter(t => t.id !== payload.old.id))
22 } else if (payload.eventType === 'UPDATE') {
23 setTasks(prev => prev.map(t =>
24 t.id === payload.new.id ? payload.new as Task : t
25 ))
26 }
27 })
28 .subscribe()
29
30 return () => { supabase.removeChannel(channel) }
31 }, [])
32
33 return <ul>{tasks.map(t => <li key={t.id}>{t.title}</li>)}</ul>
34}#8. File Storage
Supabase Storage makes handling file uploads simple. Here's how to upload an avatar:
1async function uploadAvatar(file: File, userId: string) {
2 const supabase = createClient()
3 const ext = file.name.split('.').pop()
4 const path = `${userId}/avatar.${ext}`
5
6 const { error } = await supabase.storage
7 .from('avatars')
8 .upload(path, file, { upsert: true })
9
10 if (error) throw error
11
12 const { data } = supabase.storage.from('avatars').getPublicUrl(path)
13 return data.publicUrl
14}#9. Deployment to Vercel
Deploying a Next.js + Supabase app to Vercel takes under 5 minutes:
Push your code to GitHub.
Import the repo in the Vercel dashboard.
Add your environment variables (
NEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEY).Click Deploy.
Vercel automatically detects Next.js and configures the build correctly. Your app will be live in ~2 minutes.
#Performance Comparison
Feature | Next.js + Supabase | Traditional REST API | Firebase |
|---|---|---|---|
Cold start | ~50ms | ~200ms | ~80ms |
Auth setup time | 15 min | 2–4 hrs | 30 min |
Real-time | ✅ Built-in | ❌ Custom | ✅ Built-in |
SQL support | ✅ Full PostgreSQL |
| ❌ NoSQL only |
Free tier DB size | 500 MB | N/A | 1 GB |
#Conclusion
Next.js 15 and Supabase together form one of the most powerful and developer-friendly stacks available today. You get the performance of React Server Components, the simplicity of a managed Postgres backend, and the flexibility to scale from side project to production app without changing your architecture.
The patterns covered in this guide RLS, server-side auth, real-time subscriptions, and file storage are the foundation of almost every full-stack app. Start with this as your base and build from there.
Happy shipping! 🚀

