Realtime features like live chat, collaborative editing, and live dashboards used to require complex WebSocket infrastructure. Supabase makes realtime accessible by providing database change listeners, presence tracking, and broadcast channels out of the box — all backed by a full PostgreSQL database with row-level security. This guide builds a collaborative task board where multiple users see changes instantly, with authentication and fine-grained access control.
Prerequisites
- →Node.js 20+
Required for the React development environment and build tools.
- →Supabase Account
Create a free Supabase project to get a PostgreSQL database, authentication, and realtime subscriptions.
- →React Fundamentals
Understanding of React components, hooks (useState, useEffect), and the context API.
- →Basic SQL Knowledge
Familiarity with CREATE TABLE, SELECT, INSERT, and UPDATE statements for setting up database tables.
Create the Supabase Project and Database Schema
Set up a new Supabase project and create the database tables using the SQL editor. The task board needs tables for boards, columns, tasks, and board memberships. Enable Realtime on the tasks table so changes are broadcast to all connected clients automatically.
-- Create tables for the task board
CREATE TABLE boards (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
owner_id UUID REFERENCES auth.users(id) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE columns (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
board_id UUID REFERENCES boards(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE tasks (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
column_id UUID REFERENCES columns(id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
description TEXT,
assignee_id UUID REFERENCES auth.users(id),
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE board_members (
board_id UUID REFERENCES boards(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member' CHECK (role IN ('owner', 'member', 'viewer')),
PRIMARY KEY (board_id, user_id)
);
-- Enable Realtime for tasks table
ALTER PUBLICATION supabase_realtime ADD TABLE tasks;Tip: Enable Realtime only on tables that need it — each realtime subscription consumes a WebSocket connection.
Tip: Use UUID primary keys instead of serial integers for better distributed system compatibility.
Set Up Row-Level Security Policies
Enable row-level security (RLS) on all tables and create policies that control who can read and modify data. RLS runs at the database level, so it protects your data regardless of how clients connect. Board members can view and edit tasks, while viewers can only read them.
-- Enable RLS on all tables
ALTER TABLE boards ENABLE ROW LEVEL SECURITY;
ALTER TABLE columns ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE board_members ENABLE ROW LEVEL SECURITY;
-- Boards: members can view, owners can update/delete
CREATE POLICY "Members can view boards" ON boards
FOR SELECT USING (
id IN (SELECT board_id FROM board_members WHERE user_id = auth.uid())
);
CREATE POLICY "Authenticated users can create boards" ON boards
FOR INSERT WITH CHECK (auth.uid() = owner_id);
-- Tasks: board members can CRUD tasks
CREATE POLICY "Board members can view tasks" ON tasks
FOR SELECT USING (
column_id IN (
SELECT c.id FROM columns c
JOIN board_members bm ON bm.board_id = c.board_id
WHERE bm.user_id = auth.uid()
)
);
CREATE POLICY "Board members can insert tasks" ON tasks
FOR INSERT WITH CHECK (
column_id IN (
SELECT c.id FROM columns c
JOIN board_members bm ON bm.board_id = c.board_id
WHERE bm.user_id = auth.uid() AND bm.role IN ('owner', 'member')
)
);
CREATE POLICY "Board members can update tasks" ON tasks
FOR UPDATE USING (
column_id IN (
SELECT c.id FROM columns c
JOIN board_members bm ON bm.board_id = c.board_id
WHERE bm.user_id = auth.uid() AND bm.role IN ('owner', 'member')
)
);Tip: Always enable RLS before your app goes to production — without it, any authenticated user can access all data.
Tip: Test RLS policies using the Supabase Dashboard's SQL editor with 'SET ROLE authenticated' and 'SET request.jwt.claims'.
Scaffold the React App and Configure Supabase Client
Create a React application with Vite and set up the Supabase client. The Supabase JavaScript client handles authentication, database queries, and realtime subscriptions through a single import. Create a shared client instance that is used throughout the application.
npm create vite@latest task-board -- --template react-ts
cd task-board
npm install @supabase/supabase-js
npm installTip: Store your Supabase URL and anon key in environment variables — the anon key is safe to expose because RLS protects your data.
Implement Authentication with Supabase Auth
Set up authentication using Supabase Auth's built-in email/password and OAuth providers. Supabase Auth handles user registration, login, password reset, and session management. The auth state is stored in localStorage and synced across tabs automatically. Create an auth context to share the user session throughout your React component tree.
// src/lib/supabase.ts
import { createClient } from "@supabase/supabase-js";
import type { Database } from "./database.types";
export const supabase = createClient<Database>(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY
);
// src/hooks/useAuth.ts
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabase";
import type { User } from "@supabase/supabase-js";
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
setLoading(false);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null);
}
);
return () => subscription.unsubscribe();
}, []);
const signIn = (email: string, password: string) =>
supabase.auth.signInWithPassword({ email, password });
const signUp = (email: string, password: string) =>
supabase.auth.signUp({ email, password });
const signOut = () => supabase.auth.signOut();
return { user, loading, signIn, signUp, signOut };
}Tip: Use onAuthStateChange to react to login/logout events across browser tabs.
Tip: Generate TypeScript types from your Supabase schema with 'npx supabase gen types typescript' for full type safety.
Build the Task Board with Realtime Subscriptions
Create the main board component that loads tasks from the database and subscribes to realtime changes. When any user creates, updates, or deletes a task, all connected clients see the change instantly without refreshing. Supabase Realtime uses PostgreSQL's LISTEN/NOTIFY under the hood.
// src/hooks/useTasks.ts
import { useEffect, useState, useCallback } from "react";
import { supabase } from "../lib/supabase";
interface Task {
id: string;
column_id: string;
title: string;
description: string | null;
position: number;
assignee_id: string | null;
}
export function useTasks(boardId: string) {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
// Fetch initial tasks
useEffect(() => {
async function fetchTasks() {
const { data } = await supabase
.from("tasks")
.select("*")
.in(
"column_id",
(await supabase.from("columns").select("id").eq("board_id", boardId)).data?.map((c) => c.id) ?? []
)
.order("position");
setTasks(data ?? []);
setLoading(false);
}
fetchTasks();
}, [boardId]);
// Subscribe to realtime changes
useEffect(() => {
const channel = supabase
.channel(`board-${boardId}`)
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "tasks" },
(payload) => {
setTasks((prev) => [...prev, payload.new as Task]);
}
)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "public", table: "tasks" },
(payload) => {
setTasks((prev) =>
prev.map((t) => (t.id === (payload.new as Task).id ? (payload.new as Task) : t))
);
}
)
.on(
"postgres_changes",
{ event: "DELETE", schema: "public", table: "tasks" },
(payload) => {
setTasks((prev) => prev.filter((t) => t.id !== (payload.old as Task).id));
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [boardId]);
const addTask = useCallback(
async (columnId: string, title: string) => {
await supabase.from("tasks").insert({
column_id: columnId,
title,
position: tasks.filter((t) => t.column_id === columnId).length,
});
},
[tasks]
);
return { tasks, loading, addTask };
}Tip: Clean up realtime subscriptions in the useEffect return function to prevent memory leaks.
Tip: Filter realtime events by board ID to avoid processing updates from other boards.
Add Presence Tracking for Online Users
Implement presence tracking so users can see who else is viewing the same board in real time. Supabase Presence uses the same WebSocket connection as realtime subscriptions, so there is no additional infrastructure cost. Each user's cursor position or selected task can be shared with the presence payload.
// src/hooks/usePresence.ts
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabase";
import type { RealtimeChannel } from "@supabase/supabase-js";
interface PresenceState {
userId: string;
email: string;
color: string;
online_at: string;
}
const COLORS = ["#ef4444", "#3b82f6", "#22c55e", "#eab308", "#a855f7", "#ec4899"];
export function usePresence(boardId: string, userId: string, email: string) {
const [onlineUsers, setOnlineUsers] = useState<PresenceState[]>([]);
useEffect(() => {
const channel: RealtimeChannel = supabase.channel(`presence-${boardId}`, {
config: { presence: { key: userId } },
});
channel
.on("presence", { event: "sync" }, () => {
const state = channel.presenceState<PresenceState>();
const users = Object.values(state)
.flat()
.map((s) => ({
userId: s.userId,
email: s.email,
color: s.color,
online_at: s.online_at,
}));
setOnlineUsers(users);
})
.subscribe(async (status) => {
if (status === "SUBSCRIBED") {
await channel.track({
userId,
email,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
online_at: new Date().toISOString(),
});
}
});
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
}, [boardId, userId, email]);
return onlineUsers;
}Tip: Assign each user a random color for their presence indicator — this creates a visual distinction in the UI.
Tip: Call untrack() in the cleanup function so users disappear from the presence list when they leave the page.
Build the Board UI with Drag and Drop
Create the visual board with columns and draggable task cards. When a task is dragged to a new column or position, update the database and let the realtime subscription broadcast the change to all clients. This creates a seamless collaborative experience where everyone sees moves instantly.
// src/components/Board.tsx
import { useState } from "react";
import { useTasks } from "../hooks/useTasks";
import { usePresence } from "../hooks/usePresence";
import { useAuth } from "../hooks/useAuth";
import { supabase } from "../lib/supabase";
interface Column {
id: string;
name: string;
position: number;
}
export function Board({ boardId, columns }: { boardId: string; columns: Column[] }) {
const { user } = useAuth();
const { tasks, loading, addTask } = useTasks(boardId);
const onlineUsers = usePresence(boardId, user!.id, user!.email!);
const [newTaskTitle, setNewTaskTitle] = useState<Record<string, string>>({});
async function handleMoveTask(taskId: string, newColumnId: string, newPosition: number) {
await supabase
.from("tasks")
.update({ column_id: newColumnId, position: newPosition, updated_at: new Date().toISOString() })
.eq("id", taskId);
}
if (loading) return <div className="p-8">Loading board...</div>;
return (
<div>
<div className="mb-4 flex items-center gap-2">
<span className="text-sm text-gray-500">Online:</span>
{onlineUsers.map((u) => (
<span
key={u.userId}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ backgroundColor: u.color }}
title={u.email}
>
{u.email[0].toUpperCase()}
</span>
))}
</div>
<div className="flex gap-4 overflow-x-auto pb-4">
{columns
.sort((a, b) => a.position - b.position)
.map((column) => (
<div key={column.id} className="w-72 flex-shrink-0 rounded-lg bg-gray-100 p-4">
<h3 className="mb-3 font-semibold text-gray-700">{column.name}</h3>
<div className="space-y-2">
{tasks
.filter((t) => t.column_id === column.id)
.sort((a, b) => a.position - b.position)
.map((task) => (
<div
key={task.id}
draggable
onDragStart={(e) => e.dataTransfer.setData("taskId", task.id)}
className="cursor-grab rounded-lg border bg-white p-3 shadow-sm hover:shadow-md active:cursor-grabbing"
>
<p className="text-sm">{task.title}</p>
</div>
))}
</div>
<form
className="mt-3"
onSubmit={(e) => {
e.preventDefault();
const title = newTaskTitle[column.id]?.trim();
if (title) {
addTask(column.id, title);
setNewTaskTitle((prev) => ({ ...prev, [column.id]: "" }));
}
}}
>
<input
type="text"
value={newTaskTitle[column.id] ?? ""}
onChange={(e) =>
setNewTaskTitle((prev) => ({ ...prev, [column.id]: e.target.value }))
}
placeholder="Add a task..."
className="w-full rounded border px-3 py-2 text-sm"
/>
</form>
</div>
))}
</div>
</div>
);
}Tip: Use the HTML5 Drag and Drop API for simplicity, or upgrade to @dnd-kit for a more polished experience.
Tip: Optimistically update the local state before the database write for instant visual feedback.
Deploy with Supabase Edge Functions
Deploy your React frontend to Vercel or Netlify, and add Supabase Edge Functions for server-side logic like sending notifications when tasks are assigned. Edge Functions run on Deno Deploy, close to your users, with access to your Supabase database and auth context.
// supabase/functions/task-assigned/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
serve(async (req) => {
const { taskId, assigneeId } = await req.json();
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get task and assignee details
const [{ data: task }, { data: assignee }] = await Promise.all([
supabase.from("tasks").select("title").eq("id", taskId).single(),
supabase.auth.admin.getUserById(assigneeId),
]);
if (!task || !assignee.user) {
return new Response("Not found", { status: 404 });
}
// Send notification via email API (e.g., Resend)
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("RESEND_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "notifications@yourapp.com",
to: assignee.user.email,
subject: `You've been assigned: ${task.title}`,
text: `A task has been assigned to you: ${task.title}`,
}),
});
return new Response(JSON.stringify({ success: true }), {
headers: { "Content-Type": "application/json" },
});
});Tip: Deploy Edge Functions with 'supabase functions deploy task-assigned'.
Tip: Use the service role key in Edge Functions for admin operations — never expose it to the client.
Tip: Set up a database webhook trigger to call your Edge Function automatically when tasks are updated.
Next Steps
- →Add file attachments to tasks using Supabase Storage with signed upload URLs.
- →Implement activity logs that track every change to a task with the user who made it.
- →Build a notification center with read/unread state using Supabase Realtime broadcast.
- →Add keyboard shortcuts for power users to create, move, and assign tasks without the mouse.