Build a Real-Time App with Supabase and Next.js 15: Complete Guide

Supabase has become the go-to open-source alternative to Firebase, providing a complete backend with PostgreSQL, authentication, real-time subscriptions, and storage — all out of the box. Combined with Next.js 15 and the App Router, you get a powerful full-stack framework that handles server-side rendering, server actions, and seamless client interactions.
In this guide, you'll build a real-time collaborative task board — a Trello-lite where multiple users can add, update, and delete tasks that instantly sync across all connected browsers. Along the way, you'll master Supabase Auth, Row Level Security (RLS), database triggers, and the Realtime Broadcast system.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed
- A Supabase account — free tier at supabase.com
- Basic knowledge of React and TypeScript
- Familiarity with Next.js App Router (pages, layouts, server components)
- A code editor (VS Code recommended)
What You'll Build
A collaborative task board with these features:
- Email/password authentication with protected routes
- Real-time task updates across all connected clients
- Row Level Security ensuring users only see their own data
- Server Components for initial data loading
- Server Actions for mutations
- Middleware for session management
Step 1: Create a Supabase Project
Head to supabase.com/dashboard and create a new project. Once your project is ready, note down these values from Settings > API:
Project URL— your Supabase instance URLanon publickey — safe for client-side useservice_rolekey — admin access (keep this secret)
Step 2: Set Up the Database Schema
Go to the SQL Editor in your Supabase dashboard and run the following:
-- Create a profiles table linked to auth.users
create table public.profiles (
id uuid references auth.users on delete cascade not null primary key,
email text,
display_name text,
created_at timestamptz default now() not null
);
-- Create the tasks table
create table public.tasks (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users on delete cascade not null,
title text not null,
description text,
status text default 'todo' check (status in ('todo', 'in_progress', 'done')),
created_at timestamptz default now() not null,
updated_at timestamptz default now() not null
);
-- Enable Row Level Security
alter table public.profiles enable row level security;
alter table public.tasks enable row level security;
-- Profiles: users can read and update their own profile
create policy "Users can view own profile"
on public.profiles for select
using (auth.uid() = id);
create policy "Users can update own profile"
on public.profiles for update
using (auth.uid() = id);
-- Tasks: users can CRUD their own tasks
create policy "Users can view own tasks"
on public.tasks for select
using (auth.uid() = user_id);
create policy "Users can create tasks"
on public.tasks for insert
with check (auth.uid() = user_id);
create policy "Users can update own tasks"
on public.tasks for update
using (auth.uid() = user_id);
create policy "Users can delete own tasks"
on public.tasks for delete
using (auth.uid() = user_id);
-- Auto-create profile on signup
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, email, display_name)
values (new.id, new.email, split_part(new.email, '@', 1));
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- Auto-update updated_at on tasks
create or replace function public.update_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
create trigger tasks_updated_at
before update on public.tasks
for each row execute function public.update_updated_at();This sets up a complete schema with:
- A
profilestable that auto-populates on user signup - A
taskstable with status tracking - RLS policies so each user only accesses their own data
- Triggers for automatic timestamps
Step 3: Set Up Realtime Broadcasting
For scalable real-time updates, Supabase recommends Broadcast via database triggers over the older postgres_changes approach. Run this in the SQL Editor:
-- Broadcast trigger for tasks table
create or replace function broadcast_task_changes()
returns trigger as $$
begin
perform realtime.broadcast_changes(
'tasks:updates',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
return null;
end;
$$ language plpgsql security definer;
create trigger tasks_broadcast_trigger
after insert or update or delete on public.tasks
for each row execute function broadcast_task_changes();Step 4: Scaffold the Next.js Project
npx create-next-app@latest task-board --typescript --tailwind --eslint --app --src-dir
cd task-boardInstall the Supabase packages:
npm install @supabase/supabase-js @supabase/ssrCreate your .env.local file:
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-hereStep 5: Create the Supabase Clients
You need two clients: one for server-side (Server Components, Server Actions, Middleware) and one for the browser.
Browser Client
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}Server Client
// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
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 {
// Called from a Server Component — safe to ignore
// if middleware handles session refresh
}
},
},
}
)
}Step 6: Add Middleware for Session Management
The middleware refreshes the user session on every request, preventing expired tokens:
// src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
// Redirect unauthenticated users to login
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/signup') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// Redirect authenticated users away from auth pages
if (user && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup')) {
const url = request.nextUrl.clone()
url.pathname = '/dashboard'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}Step 7: Build the Authentication Pages
Login Page
// src/app/login/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
router.refresh()
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<h2 className="text-3xl font-bold text-center text-gray-900">
Sign in to Task Board
</h2>
<form onSubmit={handleLogin} className="space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="text-center text-sm text-gray-600">
No account?{' '}
<a href="/signup" className="text-blue-600 hover:underline">
Sign up
</a>
</p>
</div>
</div>
)
}Signup Page
// src/app/signup/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function SignupPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
const { error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
router.refresh()
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<h2 className="text-3xl font-bold text-center text-gray-900">
Create your account
</h2>
<form onSubmit={handleSignup} className="space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password (min. 6 characters)
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Sign up'}
</button>
</form>
<p className="text-center text-sm text-gray-600">
Already have an account?{' '}
<a href="/login" className="text-blue-600 hover:underline">
Sign in
</a>
</p>
</div>
</div>
)
}Step 8: Build the Dashboard with Server Components
The dashboard loads tasks server-side for fast initial page load, then subscribes to real-time changes on the client.
Dashboard Layout
// src/app/dashboard/layout.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-xl font-bold text-gray-900">Task Board</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user.email}</span>
<form action="/auth/signout" method="post">
<button
type="submit"
className="text-sm text-red-600 hover:underline"
>
Sign out
</button>
</form>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">{children}</main>
</div>
)
}Server Component: Fetch Initial Tasks
// src/app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { TaskBoard } from './task-board'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: tasks, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return <div className="text-red-600">Failed to load tasks: {error.message}</div>
}
return <TaskBoard initialTasks={tasks ?? []} />
}Step 9: Create the Real-Time Task Board Component
This is the heart of the app — a client component that renders the Kanban columns and subscribes to real-time changes:
// src/app/dashboard/task-board.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
type Task = {
id: string
title: string
description: string | null
status: 'todo' | 'in_progress' | 'done'
created_at: string
updated_at: string
user_id: string
}
const STATUS_COLUMNS = [
{ key: 'todo', label: 'To Do', color: 'bg-yellow-100 border-yellow-300' },
{ key: 'in_progress', label: 'In Progress', color: 'bg-blue-100 border-blue-300' },
{ key: 'done', label: 'Done', color: 'bg-green-100 border-green-300' },
] as const
export function TaskBoard({ initialTasks }: { initialTasks: Task[] }) {
const [tasks, setTasks] = useState<Task[]>(initialTasks)
const [newTitle, setNewTitle] = useState('')
const supabase = createClient()
// Subscribe to real-time task changes
useEffect(() => {
const channel = supabase
.channel('tasks:updates', { config: { private: true } })
.on('broadcast', { event: 'INSERT' }, (payload) => {
const newTask = payload.payload.record as Task
setTasks((prev) => [newTask, ...prev])
})
.on('broadcast', { event: 'UPDATE' }, (payload) => {
const updated = payload.payload.record as Task
setTasks((prev) =>
prev.map((t) => (t.id === updated.id ? updated : t))
)
})
.on('broadcast', { event: 'DELETE' }, (payload) => {
const deleted = payload.payload.old_record as Task
setTasks((prev) => prev.filter((t) => t.id !== deleted.id))
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
const addTask = async (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { error } = await supabase.from('tasks').insert({
title: newTitle.trim(),
user_id: user.id,
status: 'todo',
})
if (!error) setNewTitle('')
}
const updateStatus = async (taskId: string, status: Task['status']) => {
await supabase.from('tasks').update({ status }).eq('id', taskId)
}
const deleteTask = async (taskId: string) => {
await supabase.from('tasks').delete().eq('id', taskId)
}
return (
<div className="space-y-6">
{/* Add Task Form */}
<form onSubmit={addTask} className="flex gap-3">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Add a new task..."
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Add Task
</button>
</form>
{/* Kanban Columns */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{STATUS_COLUMNS.map((col) => (
<div key={col.key} className={`rounded-xl border-2 p-4 ${col.color}`}>
<h3 className="font-semibold text-lg mb-4">
{col.label}
<span className="ml-2 text-sm font-normal text-gray-500">
({tasks.filter((t) => t.status === col.key).length})
</span>
</h3>
<div className="space-y-3">
{tasks
.filter((t) => t.status === col.key)
.map((task) => (
<div
key={task.id}
className="bg-white rounded-lg p-4 shadow-sm border"
>
<h4 className="font-medium text-gray-900">{task.title}</h4>
{task.description && (
<p className="text-sm text-gray-500 mt-1">
{task.description}
</p>
)}
<div className="mt-3 flex gap-2 flex-wrap">
{col.key !== 'todo' && (
<button
onClick={() =>
updateStatus(
task.id,
col.key === 'done' ? 'in_progress' : 'todo'
)
}
className="text-xs px-2 py-1 bg-gray-100 rounded hover:bg-gray-200"
>
← Move back
</button>
)}
{col.key !== 'done' && (
<button
onClick={() =>
updateStatus(
task.id,
col.key === 'todo' ? 'in_progress' : 'done'
)
}
className="text-xs px-2 py-1 bg-blue-100 rounded hover:bg-blue-200 text-blue-700"
>
Move forward →
</button>
)}
<button
onClick={() => deleteTask(task.id)}
className="text-xs px-2 py-1 bg-red-100 rounded hover:bg-red-200 text-red-700"
>
Delete
</button>
</div>
</div>
))}
{tasks.filter((t) => t.status === col.key).length === 0 && (
<p className="text-sm text-gray-400 text-center py-4">
No tasks
</p>
)}
</div>
</div>
))}
</div>
</div>
)
}Step 10: Add the Sign-Out Route
// src/app/auth/signout/route.ts
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function POST() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}Step 11: Auth Callback Handler
Supabase sends users to a callback URL after email confirmation. Add this route:
// src/app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}/dashboard`)
}
}
return NextResponse.redirect(`${origin}/login?error=auth`)
}Testing Your Implementation
- Start the development server:
npm run dev- Sign up at
http://localhost:3000/signupwith a test email - Create tasks and move them between columns
- Open a second browser tab — changes appear in real time
- Check RLS — sign in with a different account to verify task isolation
Troubleshooting
Common issues and solutions
"relation 'tasks' does not exist" — Make sure you ran the SQL setup in Step 2 on your Supabase project.
Cookies not being set — Ensure your middleware is configured with the correct matcher pattern and that @supabase/ssr is installed.
Real-time events not arriving — Verify that the broadcast trigger was created in Step 3. Check your Supabase dashboard under Realtime > Inspector to see if events are flowing.
"Unauthorized" errors — Row Level Security is enabled. Make sure the user is authenticated before making database queries. Check that RLS policies match the auth.uid() to the correct column.
Session expired on page refresh — The middleware should handle token refresh automatically. If sessions are lost, double-check that setAll in the server client properly writes cookies.
Project Structure Recap
src/
├── app/
│ ├── auth/
│ │ ├── callback/route.ts # OAuth/email callback
│ │ └── signout/route.ts # Sign-out handler
│ ├── dashboard/
│ │ ├── layout.tsx # Auth guard + header
│ │ ├── page.tsx # Server component (data fetch)
│ │ └── task-board.tsx # Client component (real-time)
│ ├── login/page.tsx # Login form
│ └── signup/page.tsx # Signup form
├── lib/
│ └── supabase/
│ ├── client.ts # Browser client
│ └── server.ts # Server client
└── middleware.ts # Session refresh + route guards
Next Steps
Now that you have a working real-time task board, here are ways to extend it:
- Add OAuth providers — Enable Google, GitHub, or Discord login via Supabase Auth settings
- Implement drag-and-drop — Use
@dnd-kit/corefor smooth Kanban interactions - Add Supabase Storage — Let users attach files to tasks
- Deploy to Vercel — Set your environment variables and deploy with
vercel deploy - Add Presence — Show which users are currently online using Supabase Realtime Presence
Conclusion
You've built a full-stack, real-time collaborative task board using Supabase and Next.js 15. The key patterns you've learned are:
- Dual client setup — separate browser and server Supabase clients for the App Router
- Middleware session management — automatically refreshing tokens on every request
- Row Level Security — database-level access control without writing backend code
- Broadcast triggers — the scalable approach to real-time database change notifications
- Server Components + Client Components — server-side data loading with client-side interactivity
These patterns transfer to any Supabase + Next.js project, whether you're building a chat app, dashboard, or SaaS product. Supabase handles the heavy infrastructure so you can focus on your application logic.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a SaaS Starter Kit with Next.js 15, Stripe Subscriptions, and Auth.js v5
Learn how to build a production-ready SaaS application with Next.js 15, Stripe for subscription billing, and Auth.js v5 for authentication. This step-by-step tutorial covers project setup, OAuth login, pricing plans, webhook handling, and protected routes.