Skip to main content
In this tutorial, you’ll build a complete todo application with user authentication and real-time database updates using Supabase.

What You’ll Build

A todo app with the following features:
  • User authentication (sign up/login)
  • Create, read, update, and delete todos
  • Mark todos as complete
  • Row Level Security (RLS) to protect user data
  • Real-time updates

Prerequisites

Before you begin, make sure you have:
  • A Supabase account (sign up here)
  • Node.js 16+ installed
  • Basic knowledge of React and JavaScript
1

Create a Supabase Project

  1. Go to supabase.com/dashboard
  2. Click New Project
  3. Fill in your project details:
    • Name: Todo App
    • Database Password: Choose a strong password
    • Region: Select the closest region to your users
  4. Click Create new project
Wait for your database to finish setting up (this takes about 2 minutes).
2

Set Up the Database

In the SQL Editor, run the “Todo List” quickstart:
  1. Navigate to SQL Editor in the sidebar
  2. Scroll down and select TODO LIST: Build a basic todo list with Row Level Security
  3. Click Run to execute the SQL
This creates a todos table with the following schema:
create table todos (
  id bigint generated by default as identity primary key,
  user_id uuid references auth.users not null,
  task text check (char_length(task) > 3),
  is_complete boolean default false,
  inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);

alter table todos enable row level security;

create policy "Individuals can create todos." on todos for
    insert with check ((select auth.uid()) = user_id);

create policy "Individuals can view their own todos." on todos for
    select using ((select auth.uid()) = user_id);

create policy "Individuals can update their own todos." on todos for
    update using ((select auth.uid()) = user_id);

create policy "Individuals can delete their own todos." on todos for
    delete using ((select auth.uid()) = user_id);
3

Get Your API Keys

  1. Go to Project Settings (the cog icon)
  2. Click on API
  3. Find your URL and anon public key
  4. Save these for the next step
The anon key is safe to use in a browser. It allows “anonymous access” until the user logs in. Never expose your service_role key in client-side code.
4

Create a Next.js App

Create a new Next.js application:
npx create-next-app@latest my-todo-app
cd my-todo-app
Install Supabase dependencies:
npm install @supabase/supabase-js @supabase/auth-helpers-nextjs @supabase/auth-helpers-react @supabase/auth-ui-react @supabase/auth-ui-shared
5

Configure Environment Variables

Create a .env.local file in your project root:
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Replace your-project-url and your-anon-key with the values from Step 3.
6

Initialize Supabase Client

Create lib/initSupabase.ts:
import { createPagesBrowserClient } from '@supabase/auth-helpers-nextjs'
import { Database } from './schema'

export const createClient = () => createPagesBrowserClient<Database>()
7

Create the Todo Component

Create components/TodoList.tsx:
import { Database } from '@/lib/schema'
import { Session, useSupabaseClient } from '@supabase/auth-helpers-react'
import { useEffect, useState } from 'react'

type Todos = Database['public']['Tables']['todos']['Row']

export default function TodoList({ session }: { session: Session }) {
  const supabase = useSupabaseClient<Database>()
  const [todos, setTodos] = useState<Todos[]>([])
  const [newTaskText, setNewTaskText] = useState('')
  const [errorText, setErrorText] = useState('')

  const user = session.user

  useEffect(() => {
    const fetchTodos = async () => {
      const { data: todos, error } = await supabase
        .from('todos')
        .select('*')
        .order('id', { ascending: true })

      if (error) console.log('error', error)
      else setTodos(todos)
    }

    fetchTodos()
  }, [supabase])

  const addTodo = async (taskText: string) => {
    let task = taskText.trim()
    if (task.length) {
      const { data: todo, error } = await supabase
        .from('todos')
        .insert({ task, user_id: user.id })
        .select()
        .single()

      if (error) {
        setErrorText(error.message)
      } else {
        setTodos([...todos, todo])
        setNewTaskText('')
      }
    }
  }

  const deleteTodo = async (id: number) => {
    try {
      await supabase.from('todos').delete().eq('id', id).throwOnError()
      setTodos(todos.filter((x) => x.id != id))
    } catch (error) {
      console.log('error', error)
    }
  }

  const toggleComplete = async (id: number, isComplete: boolean) => {
    try {
      const { data } = await supabase
        .from('todos')
        .update({ is_complete: !isComplete })
        .eq('id', id)
        .throwOnError()
        .select()
        .single()

      if (data) {
        setTodos(todos.map(todo => todo.id === id ? data : todo))
      }
    } catch (error) {
      console.log('error', error)
    }
  }

  return (
    <div className="w-full">
      <h1 className="mb-12">Todo List</h1>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          addTodo(newTaskText)
        }}
        className="flex gap-2 my-2"
      >
        <input
          className="rounded w-full p-2"
          type="text"
          placeholder="Add a new task"
          value={newTaskText}
          onChange={(e) => {
            setErrorText('')
            setNewTaskText(e.target.value)
          }}
        />
        <button className="btn-black" type="submit">
          Add
        </button>
      </form>
      {errorText && <div className="text-red-500">{errorText}</div>}
      <div className="bg-white shadow overflow-hidden rounded-md">
        <ul>
          {todos.map((todo) => (
            <li key={todo.id} className="flex items-center p-4 border-b">
              <input
                type="checkbox"
                checked={todo.is_complete || false}
                onChange={() => toggleComplete(todo.id, todo.is_complete || false)}
                className="mr-3"
              />
              <span className={todo.is_complete ? 'line-through' : ''}>
                {todo.task}
              </span>
              <button
                onClick={() => deleteTodo(todo.id)}
                className="ml-auto text-red-500"
              >
                Delete
              </button>
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}
8

Create the Main Page

Update pages/index.tsx:
import { useSession, useSupabaseClient } from '@supabase/auth-helpers-react'
import { Auth, ThemeSupa } from '@supabase/auth-ui-react'
import TodoList from '@/components/TodoList'

export default function Home() {
  const session = useSession()
  const supabase = useSupabaseClient()

  return (
    <div className="w-full h-full bg-gray-100">
      {!session ? (
        <div className="min-w-full min-h-screen flex items-center justify-center">
          <div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
            <h1 className="text-2xl font-bold mb-4">Todo App</h1>
            <Auth supabaseClient={supabase} appearance={{ theme: ThemeSupa }} />
          </div>
        </div>
      ) : (
        <div className="max-w-2xl mx-auto p-8">
          <TodoList session={session} />
          <button
            className="mt-8 px-4 py-2 bg-red-500 text-white rounded"
            onClick={() => supabase.auth.signOut()}
          >
            Sign Out
          </button>
        </div>
      )}
    </div>
  )
}
9

Run Your App

Start the development server:
npm run dev
Open http://localhost:3000 in your browser.

Understanding Row Level Security

Row Level Security (RLS) ensures users can only access their own todos. The policies we created automatically filter queries based on the authenticated user’s ID. When a user is logged in:
  • auth.uid() returns their user ID
  • Policies check if auth.uid() = user_id
  • Only matching rows are returned

Next Steps

Add Real-time Updates

Subscribe to database changes for instant updates

User Management

Build user profiles and avatar uploads

Deploy to Vercel

Deploy your app to production

Example Code

Explore more database examples

Troubleshooting

Check that:
  • You’re logged in
  • RLS policies are enabled
  • The user_id matches auth.uid()
Verify:
  • Environment variables are set correctly
  • Email confirmation is disabled in Auth settings (for testing)
  • Check the browser console for detailed errors
Make sure your Supabase URL is correct and matches the project URL exactly.