How to Build a Full-Stack App with Supabase | Full Stack Guide
Supabase replaces your entire backend with a Postgres database, auth system, realtime engine, and file storage — all accessible through a typed client library. Instead of stitching together Firebase, Auth0, and S3, you get one SDK and one dashboard. This guide builds a full-stack task management app with Next.js that uses every major Supabase feature.
Prerequisites
- -Node.js 20+
Required for Next.js 15 with the App Router and server components.
- -Supabase Account
Create a free Supabase project to get a Postgres database, auth, realtime, and storage out of the box.
- -Supabase CLI
The Supabase CLI lets you run migrations, generate types, and test locally before deploying.
- -Basic SQL Knowledge
Familiarity with CREATE TABLE, SELECT, INSERT, and JOIN queries. Supabase is Postgres, so standard SQL applies.
Scaffold Next.js and Install the Supabase Client
Create a Next.js app and install the Supabase client libraries. The @supabase/ssr package handles cookie-based auth sessions for server components and middleware. Generate TypeScript types from your database schema so every query is type-safe.
npx create-next-app@latest taskflow --typescript --tailwind --app --src-dir
cd taskflow
npm install @supabase/supabase-js @supabase/ssr
# Initialize Supabase locally
npx supabase init
npx supabase start
# Generate types from your database schema
npx supabase gen types typescript --local > src/lib/database.types.tsTip: Run 'npx supabase start' to get a local Postgres + Auth + Storage instance for development — no cloud project needed.
Tip: Regenerate types after every migration with 'supabase gen types typescript' to keep your client type-safe.
Create the Database Schema with Row-Level Security
Define your tables and enable Row-Level Security (RLS) so each user can only access their own data. RLS policies run at the Postgres level — even if your app code has a bug, unauthorized data access is impossible. Write a migration that creates the projects and tasks tables with foreign keys and policies.
-- supabase/migrations/001_create_tables.sql
-- Projects table
create table public.projects (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
name text not null,
description text,
created_at timestamptz default now() not null
);
alter table public.projects enable row level security;
create policy "Users can CRUD their own projects"
on public.projects for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- Tasks table
create table public.tasks (
id uuid default gen_random_uuid() primary key,
project_id uuid references public.projects(id) on delete cascade not null,
user_id uuid references auth.users(id) on delete cascade not null,
title text not null,
description text,
status text default 'todo' check (status in ('todo', 'in_progress', 'done')),
priority integer default 0,
due_date timestamptz,
created_at timestamptz default now() not null,
updated_at timestamptz default now() not null
);
alter table public.tasks enable row level security;
create policy "Users can CRUD their own tasks"
on public.tasks for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- Index for fast queries
create index tasks_project_id_idx on public.tasks(project_id);
create index tasks_user_status_idx on public.tasks(user_id, status);Tip: Always enable RLS on every table — tables without RLS are publicly accessible through the Supabase API.
Tip: Use auth.uid() in policies to reference the currently authenticated user's ID.
Set Up Authentication with Server-Side Sessions
Configure the Supabase client for both server and client components. The server client reads the auth session from cookies, while the client-side instance handles login and sign-up flows. Create utility functions that return the correct client for each context.
// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import type { Database } from '@/lib/database.types';
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
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 {
// Ignore in Server Components (read-only)
}
},
},
}
);
}
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
import type { Database } from '@/lib/database.types';
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}Tip: Pass the Database generic to createServerClient for end-to-end type safety on all queries.
Tip: The server client wraps Next.js cookies() so Supabase auth works seamlessly with server components and middleware.
Build CRUD Operations with Type-Safe Queries
Create server actions for creating, reading, updating, and deleting tasks. Supabase's query builder chains like Prisma but runs directly against Postgres. Because RLS is enabled, you don't need to filter by user_id in your queries — the policy handles authorization automatically.
// src/app/projects/[id]/actions.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
export async function createTask(projectId: string, 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('tasks').insert({
project_id: projectId,
user_id: user.id,
title: formData.get('title') as string,
description: formData.get('description') as string,
status: 'todo',
});
if (error) throw new Error(error.message);
revalidatePath(`/projects/${projectId}`);
}
export async function updateTaskStatus(
taskId: string,
status: 'todo' | 'in_progress' | 'done',
projectId: string
) {
const supabase = await createClient();
const { error } = await supabase
.from('tasks')
.update({ status, updated_at: new Date().toISOString() })
.eq('id', taskId);
if (error) throw new Error(error.message);
revalidatePath(`/projects/${projectId}`);
}
export async function deleteTask(taskId: string, projectId: string) {
const supabase = await createClient();
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', taskId);
if (error) throw new Error(error.message);
revalidatePath(`/projects/${projectId}`);
}Tip: Use revalidatePath after mutations to update server component data without a full page reload.
Tip: Supabase returns typed data based on your Database type — hover over query results to see the exact shape.
Add Realtime Subscriptions for Live Updates
Subscribe to database changes so the UI updates instantly when tasks are created, updated, or deleted — even by other users on the same project. Supabase's Realtime engine uses WebSockets and Postgres logical replication. The client subscribes to a channel scoped to a specific project's tasks.
// src/hooks/useRealtimeTasks.ts
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import type { Database } from '@/lib/database.types';
type Task = Database['public']['Tables']['tasks']['Row'];
export function useRealtimeTasks(projectId: string, initialTasks: Task[]) {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const supabase = createClient();
useEffect(() => {
setTasks(initialTasks);
}, [initialTasks]);
useEffect(() => {
const channel = supabase
.channel(`tasks:${projectId}`)
.on<Task>(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'tasks',
filter: `project_id=eq.${projectId}`,
},
(payload) => {
if (payload.eventType === 'INSERT') {
setTasks(prev => [...prev, payload.new]);
} else if (payload.eventType === 'UPDATE') {
setTasks(prev =>
prev.map(t => (t.id === payload.new.id ? payload.new : t))
);
} else if (payload.eventType === 'DELETE') {
setTasks(prev =>
prev.filter(t => t.id !== payload.old.id)
);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [projectId, supabase]);
return tasks;
}Tip: Enable Realtime for specific tables in the Supabase Dashboard under Database > Replication.
Tip: Always clean up channels in the useEffect return function to prevent memory leaks and duplicate subscriptions.
Add File Storage for Task Attachments
Use Supabase Storage to let users upload file attachments to tasks. Storage uses the same RLS-style policies as your database, so files are access-controlled per user. Create a storage bucket with upload limits and a server action that handles the file upload and links it to a task.
// src/app/projects/[id]/upload-action.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
export async function uploadAttachment(
taskId: string,
projectId: string,
formData: FormData
) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const file = formData.get('file') as File;
if (!file || file.size === 0) throw new Error('No file provided');
const filePath = `${user.id}/${taskId}/${file.name}`;
const { error: uploadError } = await supabase.storage
.from('attachments')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false,
});
if (uploadError) throw new Error(uploadError.message);
// Get the public URL
const { data: { publicUrl } } = supabase.storage
.from('attachments')
.getPublicUrl(filePath);
// Save reference in database
const { error: dbError } = await supabase.from('task_attachments').insert({
task_id: taskId,
user_id: user.id,
file_name: file.name,
file_url: publicUrl,
file_size: file.size,
});
if (dbError) throw new Error(dbError.message);
revalidatePath(`/projects/${projectId}`);
}Tip: Set storage policies in SQL: create policy on storage.objects using (auth.uid()::text = (storage.foldername(name))[1]).
Tip: Use upsert: false to prevent overwriting existing files — append a timestamp to filenames for uniqueness.
Deploy to Vercel with Supabase Integration
Deploy your Next.js app to Vercel and connect it to your Supabase cloud project. Vercel's Supabase integration automatically syncs environment variables. Run your migrations against the production database and configure auth redirect URLs for your production domain.
# Link to your Supabase cloud project
npx supabase link --project-ref YOUR_PROJECT_REF
# Push migrations to production
npx supabase db push
# Generate types from production schema
npx supabase gen types typescript --linked > src/lib/database.types.ts
# Deploy to Vercel
npm install -g vercel
vercel
vercel env add NEXT_PUBLIC_SUPABASE_URL
vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY
vercel --prodTip: Add your Vercel deployment URL to Supabase Auth > URL Configuration > Redirect URLs.
Tip: Use 'supabase db push' for initial deploys and 'supabase db reset' to replay all migrations from scratch locally.
Tip: Enable Supabase's built-in Postgres backups (point-in-time recovery) on the Pro plan for production.
Next Steps
- -Add Supabase Edge Functions for server-side logic like sending email notifications on task assignment.
- -Implement team collaboration with shared projects using a members junction table and RLS policies.
- -Add full-text search on tasks using Postgres tsvector columns and Supabase's textSearch filter.
- -Set up database webhooks to trigger external integrations when task status changes.
Need help building this?
I've shipped production projects with these stacks. Let's build yours together.
Let's Talk