Supabase with React: Building a Todo App Tutorial

Building a complete todo application demonstrates all fundamental Supabase concepts in one practical project—database table creation, CRUD operations, user authentication, Row Level Security policies, real-time updates, and production-ready React component architecture. This hands-on tutorial guides you through creating a fully functional task management app where users can sign up, log in, create todos, mark them complete, filter by status, and see real-time updates when todos change. We'll build reusable React components following best practices, implement proper state management, handle errors gracefully, add loading states, and deploy a production-ready application. Unlike basic examples focusing on single features, this comprehensive tutorial integrates database queries, authentication, security policies, and real-time synchronization demonstrating how Supabase features work together in real applications. By the end, you'll have a complete working app and deep understanding of building production Supabase applications. Before starting, ensure you've completed Supabase setup and understand JavaScript client basics.
What We'll Build
- User Authentication: Sign up, login, and logout with email/password
- CRUD Operations: Create, read, update, and delete todos
- Real-time Updates: See changes instantly when todos are added/modified
- Status Filtering: Filter todos by all, active, or completed
- User Isolation: Row Level Security ensuring users only see their own todos
- Responsive UI: Clean, modern interface with loading states
Database Schema
-- Create todos table
create table todos (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
title text not null,
is_complete boolean default false,
created_at timestamp with time zone default now() not null
);
-- Create index for faster queries
create index todos_user_id_idx on todos(user_id);
-- Enable Row Level Security
alter table todos enable row level security;
-- RLS Policies: Users can only access their own todos
create policy "Users can view their own todos"
on todos for select
using ( auth.uid() = user_id );
create policy "Users can create their own todos"
on todos for insert
with check ( auth.uid() = user_id );
create policy "Users can update their own todos"
on todos for update
using ( auth.uid() = user_id );
create policy "Users can delete their own todos"
on todos for delete
using ( auth.uid() = user_id );
-- Enable real-time
alter publication supabase_realtime add table todos;The todos table stores tasks with a foreign key to auth.users ensuring each todo belongs to a specific user. Row Level Security policies guarantee users can only access their own todos. Learn more about table creation and RLS policies.
Project Setup
# Create React app
npx create-react-app supabase-todo-app
cd supabase-todo-app
# Install Supabase client
npm install @supabase/supabase-js
# Create environment file
# .env.local
REACT_APP_SUPABASE_URL=your-project-url
REACT_APP_SUPABASE_ANON_KEY=your-anon-key
# Project structure
# src/
# components/
# Auth.jsx
# TodoList.jsx
# TodoItem.jsx
# supabaseClient.js
# App.js
# App.cssAuthentication Component
// Auth.jsx - Simple authentication
import { useState } from 'react'
import { supabase } from '../supabaseClient'
function Auth() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [isSignUp, setIsSignUp] = useState(false)
async function handleAuth(e) {
e.preventDefault()
setLoading(true)
const { error } = isSignUp
? await supabase.auth.signUp({ email, password })
: await supabase.auth.signInWithPassword({ email, password })
if (error) {
alert(error.message)
} else if (isSignUp) {
alert('Check your email for verification!')
}
setLoading(false)
}
return (
<div className="auth-container">
<h2>{isSignUp ? 'Sign Up' : 'Log In'}</h2>
<form onSubmit={handleAuth}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Loading...' : (isSignUp ? 'Sign Up' : 'Log In')}
</button>
</form>
<button onClick={() => setIsSignUp(!isSignUp)}>
{isSignUp ? 'Already have an account? Log In' : "Don't have an account? Sign Up"}
</button>
</div>
)
}
export default AuthTodo List Component
// TodoList.jsx - Complete todo app with real-time
import { useEffect, useState } from 'react'
import { supabase } from '../supabaseClient'
function TodoList() {
const [todos, setTodos] = useState([])
const [newTodo, setNewTodo] = useState('')
const [loading, setLoading] = useState(false)
const [filter, setFilter] = useState('all') // 'all', 'active', 'completed'
useEffect(() => {
fetchTodos()
subscribeToTodos()
}, [])
async function fetchTodos() {
const { data, error } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: false })
if (error) {
console.error('Error fetching todos:', error)
} else {
setTodos(data)
}
}
function subscribeToTodos() {
const channel = supabase
.channel('todos-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'todos' },
(payload) => {
if (payload.eventType === 'INSERT') {
setTodos(prev => [payload.new, ...prev])
} else if (payload.eventType === 'UPDATE') {
setTodos(prev => prev.map(todo =>
todo.id === payload.new.id ? payload.new : todo
))
} else if (payload.eventType === 'DELETE') {
setTodos(prev => prev.filter(todo => todo.id !== payload.old.id))
}
}
)
.subscribe()
return () => supabase.removeChannel(channel)
}
async function addTodo(e) {
e.preventDefault()
if (!newTodo.trim()) return
setLoading(true)
const { data: { user } } = await supabase.auth.getUser()
const { error } = await supabase
.from('todos')
.insert([{ title: newTodo, user_id: user.id }])
if (error) {
alert(error.message)
} else {
setNewTodo('')
}
setLoading(false)
}
async function toggleTodo(id, currentStatus) {
const { error } = await supabase
.from('todos')
.update({ is_complete: !currentStatus })
.eq('id', id)
if (error) alert(error.message)
}
async function deleteTodo(id) {
const { error } = await supabase
.from('todos')
.delete()
.eq('id', id)
if (error) alert(error.message)
}
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.is_complete
if (filter === 'completed') return todo.is_complete
return true
})
return (
<div className="todo-app">
<h1>My Todos</h1>
{/* Add todo form */}
<form onSubmit={addTodo}>
<input
type="text"
placeholder="What needs to be done?"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
/>
<button type="submit" disabled={loading}>
{loading ? 'Adding...' : 'Add'}
</button>
</form>
{/* Filter buttons */}
<div className="filters">
<button onClick={() => setFilter('all')} className={filter === 'all' ? 'active' : ''}>
All ({todos.length})
</button>
<button onClick={() => setFilter('active')} className={filter === 'active' ? 'active' : ''}>
Active ({todos.filter(t => !t.is_complete).length})
</button>
<button onClick={() => setFilter('completed')} className={filter === 'completed' ? 'active' : ''}>
Completed ({todos.filter(t => t.is_complete).length})
</button>
</div>
{/* Todo list */}
<ul className="todo-list">
{filteredTodos.map(todo => (
<li key={todo.id} className={todo.is_complete ? 'completed' : ''}>
<input
type="checkbox"
checked={todo.is_complete}
onChange={() => toggleTodo(todo.id, todo.is_complete)}
/>
<span>{todo.title}</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
)
}
export default TodoListMain App Component
// App.js - Main application with auth state
import { useEffect, useState } from 'react'
import { supabase } from './supabaseClient'
import Auth from './components/Auth'
import TodoList from './components/TodoList'
import './App.css'
function App() {
const [session, setSession] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
setLoading(false)
})
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session)
}
)
return () => subscription.unsubscribe()
}, [])
async function handleLogout() {
await supabase.auth.signOut()
}
if (loading) {
return <div className="loading">Loading...</div>
}
return (
<div className="app">
{!session ? (
<Auth />
) : (
<div>
<header>
<h1>Todo App</h1>
<div className="user-info">
<span>{session.user.email}</span>
<button onClick={handleLogout}>Logout</button>
</div>
</header>
<TodoList />
</div>
)}
</div>
)
}
export default AppStyling the App
/* App.css - Basic styling */
.app {
max-width: 600px;
margin: 0 auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #ddd;
}
.auth-container {
max-width: 400px;
margin: 100px auto;
padding: 30px;
border: 1px solid #ddd;
border-radius: 8px;
}
form {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
input[type="text"],
input[type="email"],
input[type="password"] {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 10px 20px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #2563eb;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
margin-bottom: 8px;
background: #f9fafb;
border-radius: 4px;
}
.todo-list li.completed span {
text-decoration: line-through;
color: #999;
}
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.filters button {
background: #e5e7eb;
color: #374151;
}
.filters button.active {
background: #3b82f6;
color: white;
}
.error {
color: #dc2626;
margin: 10px 0;
}Running the Application
# Start development server
npm start
# App opens at http://localhost:3000
# Test the app:
# 1. Sign up with email/password
# 2. Add some todos
# 3. Mark todos as complete
# 4. Filter by status
# 5. Delete completed todos
# 6. Open another browser tab - see real-time updates!
# Build for production
npm run build
# Deploy to Vercel, Netlify, or any hostingPossible Enhancements
- Edit Todos: Add inline editing to modify todo titles
- Due Dates: Add date picker for todo deadlines
- Categories: Organize todos with tags or categories
- Priority Levels: Mark todos as high, medium, or low priority
- File Attachments: Add Supabase Storage for todo attachments
- Sharing: Share todo lists with other users using foreign keys
Conclusion
Building a complete todo application integrates all fundamental Supabase features—database with proper schema design, CRUD operations through the JavaScript client, user authentication with email/password, Row Level Security ensuring data isolation, and real-time subscriptions for instant updates across clients. This practical project demonstrates how Supabase features work together in production applications, from initial authentication through data operations to real-time synchronization. The patterns learned here—component architecture, state management, error handling, and RLS implementation—apply to any application type from social networks to SaaS platforms. Always remember to enable RLS on tables, handle errors gracefully, implement loading states, and cleanup subscriptions to prevent memory leaks. With this foundation, you're equipped to build sophisticated applications leveraging Supabase's full capabilities. Continue your journey with Next.js integration for server-side rendering and production deployments.
$ share --platform
$ cat /comments/ (0)
$ cat /comments/
// No comments found. Be the first!


