$ cat /posts/supabase-with-react-building-a-todo-app-tutorial.md
[tags]Supabase

Supabase with React: Building a Todo App Tutorial

drwxr-xr-x2026-01-255 min0 views
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

sqlschema.sql
-- 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

bashsetup.sh
# 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.css

Authentication Component

javascriptAuth.jsx
// 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 Auth

Todo List Component

javascriptTodoList.jsx
// 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 TodoList

Main App Component

javascriptApp.js
// 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 App

Styling the App

cssApp.css
/* 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

bashrun.sh
# 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 hosting

Possible 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
Congratulations! You've built a complete CRUD app with authentication, real-time updates, and security. This project demonstrates core Supabase concepts. Next, build production apps with Next.js integration for server-side rendering and advanced features.

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.

$ cat /comments/ (0)

new_comment.sh

// Email hidden from public

>_

$ cat /comments/

// No comments found. Be the first!

[session] guest@{codershandbook}[timestamp] 2026

Navigation

Categories

Connect

Subscribe

// 2026 {Coders Handbook}. EOF.