# How to Use Supabase with React

- Tool: Supabase
- Difficulty: Beginner
- Time required: 15-20 min
- Compatibility: React 18+, @supabase/supabase-js v2+, Supabase (all plans)
- Last updated: March 2026

## TL;DR

To use Supabase with React, install @supabase/supabase-js, create a client with your project URL and anon key, and call supabase.from('table').select() to fetch data. Build CRUD components using useState and useEffect hooks. For real-time updates, subscribe to database changes with supabase.channel(). Always enable RLS on your tables and create policies before performing inserts, updates, or deletes.

## Building React Apps with Supabase

Supabase provides a JavaScript client that works seamlessly with React. You can fetch data, insert records, handle auth, and subscribe to real-time changes using the same client instance. This tutorial covers setting up the client, building a todo list with full CRUD operations, and adding real-time subscriptions so your UI updates when data changes in the database.

## Before you start

- A React project (Create React App, Vite, or Next.js)
- A Supabase project with at least one table
- Node.js 18+ installed
- SUPABASE_URL and SUPABASE_ANON_KEY from Dashboard > Settings > API

## Step-by-step guide

### 1. Install @supabase/supabase-js and create the client

Install the Supabase JS client and create a singleton instance that you can import throughout your React app. Store the URL and anon key in environment variables. For Vite projects, use VITE_ prefix; for Create React App, use REACT_APP_ prefix. Create the client in a separate file so it is only initialized once.

```
npm install @supabase/supabase-js

// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
)
```

**Expected result:** A singleton Supabase client is available to import in any component.

### 2. Set up the database table with RLS policies

Before querying from React, ensure your table has RLS enabled and appropriate policies. Without RLS, the anon key grants full access to the table. With RLS enabled but no policies, all operations return empty results. Create policies that match your app's access pattern — for a todo app, each user should only see and modify their own todos.

```
-- Run in Supabase SQL Editor
create table public.todos (
  id bigint generated always as identity primary key,
  task text not null,
  is_complete boolean default false,
  user_id uuid not null references auth.users on delete cascade,
  created_at timestamptz default now()
);

alter table public.todos enable row level security;

create policy "Users can read own todos"
  on public.todos for select
  to authenticated
  using ((select auth.uid()) = user_id);

create policy "Users can insert own todos"
  on public.todos for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

create policy "Users can update own todos"
  on public.todos for update
  to authenticated
  using ((select auth.uid()) = user_id);

create policy "Users can delete own todos"
  on public.todos for delete
  to authenticated
  using ((select auth.uid()) = user_id);

-- Add to realtime publication for subscriptions
alter publication supabase_realtime add table todos;
```

**Expected result:** The todos table is created with RLS policies that restrict access to the authenticated owner of each row.

### 3. Build a component that fetches and displays data

Use useEffect to fetch data when the component mounts, and useState to store the results. Call supabase.from('todos').select() to get rows from the table. The Supabase client automatically includes the auth token if the user is logged in, so RLS policies are enforced. Handle loading and error states for a good user experience.

```
import { useEffect, useState } from 'react'
import { supabase } from '../lib/supabase'

interface Todo {
  id: number
  task: string
  is_complete: boolean
}

export function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    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 || [])
      setLoading(false)
    }

    fetchTodos()
  }, [])

  if (loading) return <p>Loading...</p>

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <span style={{ textDecoration: todo.is_complete ? 'line-through' : 'none' }}>
            {todo.task}
          </span>
        </li>
      ))}
    </ul>
  )
}
```

**Expected result:** The component renders a list of the authenticated user's todos, fetched from Supabase on mount.

### 4. Add create, update, and delete operations

Build functions for each CRUD operation and wire them to UI elements. For inserts, include the user_id so the RLS policy allows the operation. For updates and deletes, use .eq('id', id) to target a specific row. After each mutation, update the local state to keep the UI in sync without re-fetching.

```
async function addTodo(task: string, userId: string) {
  const { data, error } = await supabase
    .from('todos')
    .insert({ task, user_id: userId })
    .select()
    .single()

  if (error) {
    console.error('Insert error:', error)
    return null
  }
  return data
}

async function toggleTodo(id: number, isComplete: boolean) {
  const { error } = await supabase
    .from('todos')
    .update({ is_complete: !isComplete })
    .eq('id', id)

  if (error) console.error('Update error:', error)
}

async function deleteTodo(id: number) {
  const { error } = await supabase
    .from('todos')
    .delete()
    .eq('id', id)

  if (error) console.error('Delete error:', error)
}
```

**Expected result:** Users can add new todos, toggle completion, and delete todos. RLS ensures they can only modify their own records.

### 5. Subscribe to real-time changes for live updates

Supabase Realtime lets you listen for INSERT, UPDATE, and DELETE events on a table. Subscribe to a channel in useEffect and update local state when changes arrive. This makes your app feel live — when data changes (from another device, tab, or user), the UI updates automatically. Always clean up subscriptions in the useEffect cleanup function to prevent memory leaks.

```
useEffect(() => {
  // Initial fetch
  fetchTodos()

  // Subscribe to real-time changes
  const channel = supabase
    .channel('todos-changes')
    .on(
      'postgres_changes',
      { event: '*', schema: 'public', table: 'todos' },
      (payload) => {
        if (payload.eventType === 'INSERT') {
          setTodos((prev) => [payload.new as Todo, ...prev])
        }
        if (payload.eventType === 'UPDATE') {
          setTodos((prev) =>
            prev.map((t) => (t.id === payload.new.id ? payload.new as Todo : t))
          )
        }
        if (payload.eventType === 'DELETE') {
          setTodos((prev) => prev.filter((t) => t.id !== payload.old.id))
        }
      }
    )
    .subscribe()

  // Cleanup subscription on unmount
  return () => {
    supabase.removeChannel(channel)
  }
}, [])
```

**Expected result:** The todo list updates in real-time when records are inserted, updated, or deleted from any client.

## Complete code example

File: `src/components/TodoApp.tsx`

```typescript
import { useEffect, useState } from 'react'
import { supabase } from '../lib/supabase'

interface Todo {
  id: number
  task: string
  is_complete: boolean
  user_id: string
}

export function TodoApp({ userId }: { userId: string }) {
  const [todos, setTodos] = useState<Todo[]>([])
  const [newTask, setNewTask] = useState('')
  const [loading, setLoading] = useState(true)

  async function fetchTodos() {
    const { data, error } = await supabase
      .from('todos')
      .select('*')
      .order('created_at', { ascending: false })
    if (!error) setTodos(data || [])
    setLoading(false)
  }

  async function addTodo(e: React.FormEvent) {
    e.preventDefault()
    if (!newTask.trim()) return
    await supabase
      .from('todos')
      .insert({ task: newTask, user_id: userId })
    setNewTask('')
  }

  async function toggleTodo(todo: Todo) {
    await supabase
      .from('todos')
      .update({ is_complete: !todo.is_complete })
      .eq('id', todo.id)
  }

  async function deleteTodo(id: number) {
    await supabase.from('todos').delete().eq('id', id)
  }

  useEffect(() => {
    fetchTodos()

    const channel = supabase
      .channel('todos-realtime')
      .on('postgres_changes',
        { event: '*', schema: 'public', table: 'todos' },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setTodos((prev) => [payload.new as Todo, ...prev])
          } else if (payload.eventType === 'UPDATE') {
            setTodos((prev) =>
              prev.map((t) => t.id === payload.new.id ? payload.new as Todo : t)
            )
          } else if (payload.eventType === 'DELETE') {
            setTodos((prev) => prev.filter((t) => t.id !== payload.old.id))
          }
        }
      )
      .subscribe()

    return () => { supabase.removeChannel(channel) }
  }, [])

  if (loading) return <p>Loading todos...</p>

  return (
    <div>
      <form onSubmit={addTodo}>
        <input
          value={newTask}
          onChange={(e) => setNewTask(e.target.value)}
          placeholder="Add a new task"
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.is_complete}
              onChange={() => toggleTodo(todo)}
            />
            <span style={{
              textDecoration: todo.is_complete ? 'line-through' : 'none'
            }}>{todo.task}</span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  )
}
```

## Common mistakes

- **Creating the Supabase client inside a component, causing re-initialization on every render** — undefined Fix: Create the client in a separate module file (e.g., src/lib/supabase.ts) and import it into components. The client should be a singleton.
- **Not cleaning up real-time subscriptions on component unmount, causing memory leaks** — undefined Fix: Return a cleanup function from useEffect that calls supabase.removeChannel(channel). This is especially important in React Strict Mode where effects run twice in development.
- **Forgetting to add the table to the realtime publication before subscribing** — undefined Fix: Run ALTER PUBLICATION supabase_realtime ADD TABLE your_table; in the SQL Editor. Without this, the real-time subscription will not receive any events.
- **Not including user_id when inserting records, causing RLS to reject the insert silently** — undefined Fix: Always include the user_id field (set to the current user's ID) when inserting records into user-scoped tables. Get the user ID from supabase.auth.getUser().

## Best practices

- Create the Supabase client as a singleton in a separate file and import it across components
- Always enable RLS and create appropriate policies before performing any write operations from the client
- Use .order() on all SELECT queries to ensure consistent row ordering in your UI
- Clean up real-time subscriptions in the useEffect cleanup function to prevent memory leaks
- Chain .select() after .insert() to get the newly created row with its generated fields
- Store Supabase credentials in environment variables, never hardcoded in source code
- Handle both loading and error states in your components for a robust user experience
- Add the table to the realtime publication before setting up real-time subscriptions

## Frequently asked questions

### Do I need a backend to use Supabase with React?

No. The Supabase JS client connects directly to your Supabase project from the browser. RLS policies enforce access control at the database level, so you do not need a separate backend API for basic CRUD operations.

### How do I add authentication to my React + Supabase app?

Use supabase.auth.signUp() and supabase.auth.signInWithPassword() for email/password auth. Listen for auth state changes with supabase.auth.onAuthStateChange(). For SSR frameworks like Next.js, use @supabase/ssr instead.

### Why does my query return an empty array even though the table has data?

This is almost always an RLS issue. Either RLS is enabled with no policies (blocking all access), or the policy does not match the current user's role. Check that you have a SELECT policy for the authenticated or anon role.

### Can I use React Query or SWR with Supabase?

Yes. Wrap your Supabase queries in React Query's useQuery or SWR's useSWR for caching, automatic re-fetching, and optimistic updates. The Supabase client returns promises that work seamlessly with both libraries.

### How do I type the Supabase client for TypeScript?

Generate types with the Supabase CLI: supabase gen types typescript --project-id your-ref > src/types/database.ts. Then pass the type to createClient: createClient<Database>(url, key). This gives you autocomplete for table names and column types.

### Can RapidDev help build my React application with Supabase?

Yes, RapidDev can architect your React + Supabase application, set up auth flows, real-time features, database schema, and RLS policies to get your app production-ready faster.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-use-supabase-with-react
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-use-supabase-with-react
