Guide · 30 min read
Build a Real-Time App with Supabase — Database, Auth, and Live Updates in 30 Minutes
Build a real-time collaborative todo app with Supabase and Next.js. Covers Postgres database, Row Level Security, email auth, realtime subscriptions, and Edge Functions. Every step tested live on Supabase's free tier.
Table of contents
What You'll Build
A real-time collaborative todo app with Supabase and Next.js. Users sign in with email, create todos, and see updates instantly across all connected clients — no polling, no WebSockets to manage, no backend to deploy. Supabase handles the database, auth, and realtime subscriptions. You write the frontend.
What you'll learn:
- Create a Supabase project and configure it in 2 minutes
- Design a Postgres table with Row Level Security (RLS)
- Add email/password authentication with one function call
- Subscribe to database changes in real-time
- Store files with Supabase Storage
- Deploy a serverless Edge Function
- Ship to production on Vercel
What you'll need:
- A Supabase account (free, no credit card)
- Node.js 18+ and npm
- A code editor (VS Code recommended)
- 30 minutes
Real output: Every code block in this guide was tested against a live Supabase project. The screenshots show the actual dashboard.
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed (
node --versionshould show v18 or higher) - npm or pnpm installed
- A Supabase account — sign up at supabase.com (free, no credit card)
- Basic familiarity with React and TypeScript
Time estimate: 30 minutes for the full guide, including setup.
Step 1 — Create a Supabase Project
1.1 Sign up and create an organization

Go to supabase.com/dashboard and sign up with email or GitHub. Once logged in, you'll see the "New organization" screen.
- Name: Your company or team name (e.g., "My App Team")
- Type: Choose the best fit (Personal, Educational, Startup, Agency, Company)
- Plan: Start with Free ($0/month)
Click Create organization.
1.2 Create a project

After creating the organization, click New project:
- Name:
ai-guide-demo(or your app name) - Database password: Generate a strong password and save it somewhere safe — you'll need it for direct database access
- Region: Choose the region closest to your users (e.g., West EU (Ireland) for European users)
- Plan: Free
Click Create new project. Supabase provisions a dedicated Postgres database, API server, and auth service. This takes about 2 minutes.
1.3 Get your API keys
Once the project is ready, go to Project Settings → API:
- Project URL:
https://your-project-id.supabase.co - anon/public key:
eyJhbG...(safe to use in browser) - service_role key:
eyJhbG...(secret — server-side only, never expose to clients)
Save these — you'll need them for the Next.js app.
What can go wrong
- "Project creation failed" — Usually a region capacity issue. Try a different region.
- "Invalid password" — Password must be at least 8 characters with mixed case and numbers.
- Project stuck on "Setting up" — Wait 3-5 minutes. If still stuck, delete and recreate.
Step 2 — Design Your Database with RLS
2.1 Create a todos table

Go to Table Editor → New table and create a todos table:
| Column | Type | Default | Nullable |
|---|---|---|---|
| id | uuid | gen_random_uuid() | No |
| user_id | uuid | — | No (FK to auth.users) |
| title | text | — | No |
| completed | boolean | false | No |
| created_at | timestamptz | now() | No |
Click Save. Supabase creates the table with a primary key and the default columns.
2.2 Enable Row Level Security
RLS is the most important security feature in Supabase. It ensures users can only access their own data — enforced by the database, not your application code.
In the Table Editor, click the RLS toggle on the todos table to enable it. Then click Add RLS policy and create these policies:
Policy 1: Users can read their own todos
-- Policy name: "Users can read own todos"
-- Operation: SELECT
-- Target roles: authenticated
-- USING expression:
auth.uid() = user_id
Policy 2: Users can insert their own todos
-- Policy name: "Users can insert own todos"
-- Operation: INSERT
-- Target roles: authenticated
-- WITH CHECK expression:
auth.uid() = user_id
Policy 3: Users can update their own todos
-- Policy name: "Users can update own todos"
-- Operation: UPDATE
-- Target roles: authenticated
-- USING expression: auth.uid() = user_id
-- WITH CHECK expression: auth.uid() = user_id
Policy 4: Users can delete their own todos
-- Policy name: "Users can delete own todos"
-- Operation: DELETE
-- Target roles: authenticated
-- USING expression:
auth.uid() = user_id
2.3 Test RLS with SQL Editor
Go to SQL Editor and run:

-- This should return 0 rows (no todos yet, and RLS is active)
SELECT * FROM todos;
-- Check that RLS is enabled
SELECT tablename, rowsecurity FROM pg_tables WHERE tablename = 'todos';
-- Should show: todos | true
What can go wrong
- "RLS policy already exists" — You may have clicked "Save" twice. Delete the duplicate and recreate.
- "permission denied for table todos" — RLS is enabled but no SELECT policy exists. Add one.
- "new row violates row-level security policy" — The INSERT policy's WITH CHECK expression is wrong. Make sure it checks
auth.uid() = user_id.
Why RLS matters
Without RLS, anyone with your anon key (which is public) can read or write any row. With RLS, the database itself enforces access control. Even if someone bypasses your frontend, they can't access data they don't own. This is the single most important security feature in Supabase.
Step 3 — Add Email Authentication
3.1 Enable email auth

Go to Authentication → Providers and ensure Email is enabled (it is by default). Configure:
- Confirm email: Enable (users must verify their email before signing in)
- Secure email change: Enable
- Minimum password length: 8
3.2 Set up redirect URLs
Go to Authentication → URL Configuration:
- Site URL:
http://localhost:3000(for development) - Redirect URLs: Add
http://localhost:3000/auth/callbackandhttp://localhost:3000/**
For production, update these to your production domain.
3.3 Create the Next.js app
npx create-next-app@latest supabase-todo --typescript --tailwind --app --src-dir
cd supabase-todo
npm install @supabase/supabase-js
3.4 Create Supabase client
Create lib/supabase.ts:
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
For server-side usage in Next.js API routes or Server Components:
import { createClient } from '@supabase/supabase-js';
export function getSupabaseClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
Note: This project uses @supabase/supabase-js directly (not @supabase/ssr). The Supabase client handles auth tokens automatically in the browser. For server-side, pass the user's JWT if needed.
3.5 Create environment file
Create .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
3.6 Sign up and sign in
Create app/auth/actions.ts:
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
export async function signUp(formData: FormData) {
const supabase = await createClient();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (error) return { error: error.message };
revalidatePath('/', 'layout');
redirect('/auth/verify');
}
export async function signIn(formData: FormData) {
const supabase = await createClient();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
revalidatePath('/', 'layout');
redirect('/dashboard');
}
export async function signOut() {
const supabase = await createClient();
await supabase.auth.signOut();
revalidatePath('/', 'layout');
redirect('/');
}
What can go wrong
- "Email not confirmed" — Check your spam folder. The verification email may take 1-2 minutes.
- "Invalid login credentials" — Wrong password or the account doesn't exist. Try signing up first.
- "User already registered" — The email is already in use. Try signing in instead.
Step 4 — Real-Time Subscriptions
4.1 Enable realtime on the todos table
Go to Database → Replication and enable realtime for the todos table. Or run in SQL Editor:
ALTER PUBLICATION supabase_realtime ADD TABLE todos;
4.2 Create a real-time todo component
Create components/TodoList.tsx:
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import type { RealtimePostgresChangesPayload } from '@supabase/supabase-js';
interface Todo {
id: string;
title: string;
completed: boolean;
user_id: string;
created_at: string;
}
export function TodoList({ userId }: { userId: string }) {
const [todos, setTodos] = useState<Todo[]>([]);
const supabase = createClient();
useEffect(() => {
// Fetch initial todos
const fetchTodos = async () => {
const { data } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: false });
if (data) setTodos(data);
};
fetchTodos();
// Subscribe to realtime changes
const channel = supabase
.channel('todos-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'todos' },
(payload: RealtimePostgresChangesPayload<Todo>) => {
if (payload.eventType === 'INSERT') {
setTodos(prev => [payload.new, ...prev]);
} else if (payload.eventType === 'UPDATE') {
setTodos(prev => prev.map(t => t.id === payload.new.id ? payload.new : t));
} else if (payload.eventType === 'DELETE') {
setTodos(prev => prev.filter(t => t.id !== payload.old.id));
}
}
)
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [userId]);
const addTodo = async (title: string) => {
await supabase.from('todos').insert({ title, user_id: userId });
// No need to update state — realtime subscription handles it
};
const toggleTodo = async (id: string, completed: boolean) => {
await supabase.from('todos').update({ completed: !completed }).eq('id', id);
};
const deleteTodo = async (id: string) => {
await supabase.from('todos').delete().eq('id', id);
};
return (
<div className="space-y-4">
<form onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem('title') as HTMLInputElement;
if (input.value.trim()) { addTodo(input.value); input.value = ''; }
}} className="flex gap-2">
<input name="title" placeholder="Add a todo..." className="flex-1 rounded border px-3 py-2" />
<button type="submit" className="rounded bg-blue-500 px-4 py-2 text-white">Add</button>
</form>
<ul className="space-y-2">
{todos.map(todo => (
<li key={todo.id} className="flex items-center gap-2 rounded border p-3">
<input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id, todo.completed)} />
<span className={todo.completed ? 'line-through text-gray-500' : ''}>{todo.title}</span>
<button onClick={() => deleteTodo(todo.id)} className="ml-auto text-red-500">Delete</button>
</li>
))}
</ul>
{todos.length === 0 && <p className="text-gray-500">No todos yet. Add one above!</p>}
</div>
);
}
4.3 Test realtime
Open two browser tabs. Add a todo in one — it appears instantly in the other. Toggle completion — both tabs update. Delete — gone from both. No polling, no WebSockets to manage.
What can go wrong
- "Realtime not working" — Check that
ALTER PUBLICATION supabase_realtime ADD TABLE todos;was run. Also verify RLS allows the user to read the table. - "Subscription error" — The channel name must be unique per component instance. Use a unique channel name or clean up on unmount.
- "Changes not propagating" — Check the browser console for WebSocket connection errors. Supabase Realtime uses WebSockets on port 443 (wss://).
Step 5 — File Storage
5.1 Create a storage bucket

Go to Storage → New bucket:
- Name:
avatars - Public: Enable (for profile pictures)
- File size limit: 5MB
- Allowed MIME types:
image/*
5.2 Set storage policies
Go to Storage → Policies and add:
Policy 1: Users can upload their own avatar
-- Operation: INSERT
-- Target roles: authenticated
-- WITH CHECK: (storage.foldername(name))[1] = auth.uid()::text
Policy 2: Anyone can view avatars
-- Operation: SELECT
-- Target roles: anon, authenticated
-- USING: true
5.3 Upload and display files
const supabase = createClient();
// Upload
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${userId}/avatar.png`, file, { upsert: true });
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.png`);
What can go wrong
- "Bucket not found" — The bucket name is case-sensitive. Check spelling.
- "new row violates row-level security policy" — Storage policies use
storage.foldername(name)which extracts the first folder. Make sure your upload path starts with the user ID. - "File size exceeds limit" — Check the bucket's file size limit in Storage settings.
Step 6 — Edge Functions
6.1 Install Supabase CLI
npm install -g supabase
supabase login
supabase link --project-ref your-project-id
6.2 Create an edge function

Click the "Deploy a new function" button (top right of the page). A dropdown appears with three deployment options:
- Deploy via CLI — create the function locally with
supabase functions new <name>, write the code, then deploy withsupabase functions deploy <name> - Deploy via Dashboard — write the function code directly in the browser editor
- Deploy via GitHub — connect a GitHub repository and auto-deploy on push
For this guide, we'll use the CLI approach (most flexible for development).
supabase functions new hello-world
Edit supabase/functions/hello-world/index.ts:
import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
serve(async (req) => {
const { name } = await req.json();
return new Response(
JSON.stringify({ message: `Hello ${name || 'World'}!` }),
{ headers: { 'Content-Type': 'application/json' } }
);
});
6.3 Deploy
supabase functions deploy hello-world
6.4 Call from the client
const { data, error } = await supabase.functions.invoke('hello-world', {
body: { name: 'NerdLevelTech' },
});
// data: { message: "Hello NerdLevelTech!" }
What can go wrong
- "Function not found" — The function name in the invoke call must match the deployed function name exactly.
- "Deno import error" — Use Deno-compatible imports. Supabase Edge Functions run on Deno, not Node.js.
- "CORS error" — Add CORS headers to your function response if calling from the browser.
Step 7 — Deploy to Production
7.1 Deploy to Vercel
npm install -g vercel
vercel
Set environment variables in Vercel dashboard:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY
7.2 Update Supabase redirect URLs
Go to Authentication → URL Configuration and update:
- Site URL:
https://your-production-domain.com - Redirect URLs: Add
https://your-production-domain.com/auth/callback
7.3 Production checklist
- RLS enabled on all tables
- Storage policies configured
- Auth redirect URLs updated
- Environment variables set in Vercel
- Error monitoring configured (Sentry, etc.)
- Rate limiting on API routes
- CORS configured for Edge Functions
What's Next
After completing this guide, you can:
- Add OAuth providers — Google, GitHub, Apple (Authentication → Providers)
- Set up database webhooks — trigger Edge Functions on data changes
- Add full-text search — use Postgres
tsvectorfor search - Implement pagination — use
.range()in Supabase queries - Add database functions — write PL/pgSQL functions for complex queries
- Set up CI/CD — GitHub Actions for automated testing and deployment
- Monitor with Supabase Studio — built-in observability for queries, auth, and storage
Why Supabase?
Supabase gives you a production-grade backend in 2 minutes. Postgres database, authentication, file storage, real-time subscriptions, and serverless functions — all with a free tier that's generous enough to launch a real product. The RLS security model means your database enforces access control, not your application code. And because it's open source, you can self-host when you outgrow the cloud.
The biggest advantage: you own your data. It's standard PostgreSQL. No vendor lock-in. Export your data anytime. Use any Postgres tool, ORM, or client.
Product strength
Supabase is strong because:
- Open source — full control, self-hostable, no vendor lock-in
- Standard Postgres — use any SQL tool, ORM, or client
- RLS security — database-level access control, not app-level
- Real-time built-in — no separate WebSocket server needed
- Edge Functions — serverless TypeScript on Deno, global deployment
- Generous free tier — enough to launch a real product
- Great DX — client libraries for 10+ languages, TypeScript types generated from your schema
Limitations (honest):
- Free tier has resource limits (500MB DB, 1GB storage)
- Edge Functions are Deno, not Node.js (some npm packages don't work)
- Realtime has connection limits on free tier
- Self-hosting requires DevOps knowledge
- The dashboard can be slow with large datasets
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| "permission denied for table" | RLS enabled but no SELECT policy | Add a SELECT policy for the authenticated role |
| "JWT expired" | Session token expired | Call supabase.auth.refreshSession() or re-authenticate |
| "Bucket not found" | Wrong bucket name (case-sensitive) | Check bucket name in Storage dashboard |
| "Function not found" | Function name mismatch or not deployed | Run supabase functions deploy <name> |
| "Realtime not working" | Table not added to publication | Run ALTER PUBLICATION supabase_realtime ADD TABLE <table> |
| "CORS error on Edge Function" | Missing CORS headers | Add Access-Control-Allow-Origin header to function response |
Frequently Asked Questions
Is Supabase really free?
Yes. The free tier includes 2 projects, 500MB database, 1GB file storage, 2GB bandwidth, 50,000 monthly active users for auth, and 500,000 Edge Function invocations. It's enough to build and launch a real product. No credit card required.
How does Supabase compare to Firebase?
Supabase is open-source, uses PostgreSQL (not a NoSQL store), and gives you full SQL access. Firebase is proprietary and uses Firestore. Supabase offers Row Level Security, real-time subscriptions, and Edge Functions. Firebase has better offline support and a larger ecosystem. Choose Supabase if you want SQL, open source, and full control.
What is Row Level Security (RLS)?
RLS is a Postgres feature that lets you define policies controlling which rows each user can read, insert, update, or delete. Supabase uses RLS as the primary security mechanism — your database enforces access control, not your application code. This means even if someone bypasses your API, they can't access data they shouldn't see.
Can I use Supabase with frameworks other than Next.js?
Yes. Supabase has client libraries for JavaScript/TypeScript, Python, Flutter, Swift, Kotlin, and more. It works with React, Vue, Svelte, Angular, Nuxt, SvelteKit, Remix, and any framework that can make HTTP requests. The database is standard PostgreSQL, so any Postgres client works.
How does Supabase Realtime work?
Supabase Realtime uses PostgreSQL's logical replication to stream database changes (INSERT, UPDATE, DELETE) to connected clients via WebSockets. You subscribe to changes on a table or specific rows, and Supabase pushes updates in real-time. It also supports presence (who's online) and broadcast (send messages between clients).
What are Edge Functions and when should I use them?
Edge Functions are serverless TypeScript functions that run on Deno at the edge (close to users). Use them for: webhooks, custom API endpoints, server-side logic that needs secrets, integrating third-party services, and processing data before storing it. They're deployed globally and start in under 50ms.
How much does Supabase cost at scale?
Free tier: 2 projects, 500MB DB, 1GB storage, 50K MAU. Pro ($25/mo): 8GB DB, 100GB storage, 100K MAU, daily backups. Team ($599/mo): everything in Pro plus SOC2, SSO, priority support. Enterprise: custom pricing. Most apps stay on Pro for a long time — the free tier is generous for prototyping.
Can I self-host Supabase?
Yes. Supabase is fully open source. You can self-host with Docker Compose on any server. The self-hosted version includes everything: Postgres, Auth, Storage, Edge Functions, Realtime, and the Studio dashboard. It's the same codebase as the cloud version.
Related articles
Building Trustworthy AI: LLM Guardrails in Real‑World Applications
LLM guardrails in real apps: input/output filtering, topic restrictions, compliance (GDPR, HIPAA), and the evaluation harnesses to prove trust in production.
Inside AI Coding Agents: How Autonomous Dev Workflows Are Evolving
Inside AI coding agents in 2026: architecture, planning loops, tool use, and frameworks like LangChain, CrewAI, and MCP. How autonomous dev workflows evolved from autocomplete to shipping whole features.
Prompt Engineering Mastery: The Art and Science of Talking to AI
A deep-dive into mastering prompt engineering — from crafting effective prompts to scaling AI workflows with reliability, performance, and precision.
More guides
Guide · 25 min read
Developer's Guide to the Model Context Protocol (MCP)
Master MCP from architecture to production. Learn the client-server protocol, build custom servers with TypeScript and Python SDKs, connect to real MCP clients, and deploy secure integrations for AI-powered workflows.
Guide · 20 min read
Build a RAG Chatbot with Dify — No Code, Real Output in 5 Steps
Build a fully working RAG chatbot in Dify's visual builder — no code required. Every step was executed live on cloud.dify.ai and produces real output. Covers model setup, Knowledge Base creation with hybrid search, Chatflow wiring, live testing, and API integration.
For AI agents:/for-agents/llms.txt/guides/supabase-realtime-app.md