Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Use Supabase with React

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.

What you'll learn

  • How to initialize the Supabase client in a React application
  • How to build CRUD components that read and write data from Supabase
  • How to subscribe to real-time database changes in React components
  • How to structure the Supabase client as a singleton for reuse across components
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner8 min read15-20 minReact 18+, @supabase/supabase-js v2+, Supabase (all plans)March 2026RapidDev Engineering Team
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.

Prerequisites

  • 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.

typescript
1npm install @supabase/supabase-js
2
3// src/lib/supabase.ts
4import { createClient } from '@supabase/supabase-js'
5
6export const supabase = createClient(
7 import.meta.env.VITE_SUPABASE_URL,
8 import.meta.env.VITE_SUPABASE_ANON_KEY
9)

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.

typescript
1-- Run in Supabase SQL Editor
2create table public.todos (
3 id bigint generated always as identity primary key,
4 task text not null,
5 is_complete boolean default false,
6 user_id uuid not null references auth.users on delete cascade,
7 created_at timestamptz default now()
8);
9
10alter table public.todos enable row level security;
11
12create policy "Users can read own todos"
13 on public.todos for select
14 to authenticated
15 using ((select auth.uid()) = user_id);
16
17create policy "Users can insert own todos"
18 on public.todos for insert
19 to authenticated
20 with check ((select auth.uid()) = user_id);
21
22create policy "Users can update own todos"
23 on public.todos for update
24 to authenticated
25 using ((select auth.uid()) = user_id);
26
27create policy "Users can delete own todos"
28 on public.todos for delete
29 to authenticated
30 using ((select auth.uid()) = user_id);
31
32-- Add to realtime publication for subscriptions
33alter 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.

typescript
1import { useEffect, useState } from 'react'
2import { supabase } from '../lib/supabase'
3
4interface Todo {
5 id: number
6 task: string
7 is_complete: boolean
8}
9
10export function TodoList() {
11 const [todos, setTodos] = useState<Todo[]>([])
12 const [loading, setLoading] = useState(true)
13
14 useEffect(() => {
15 async function fetchTodos() {
16 const { data, error } = await supabase
17 .from('todos')
18 .select('*')
19 .order('created_at', { ascending: false })
20
21 if (error) console.error('Error fetching todos:', error)
22 else setTodos(data || [])
23 setLoading(false)
24 }
25
26 fetchTodos()
27 }, [])
28
29 if (loading) return <p>Loading...</p>
30
31 return (
32 <ul>
33 {todos.map((todo) => (
34 <li key={todo.id}>
35 <span style={{ textDecoration: todo.is_complete ? 'line-through' : 'none' }}>
36 {todo.task}
37 </span>
38 </li>
39 ))}
40 </ul>
41 )
42}

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.

typescript
1async function addTodo(task: string, userId: string) {
2 const { data, error } = await supabase
3 .from('todos')
4 .insert({ task, user_id: userId })
5 .select()
6 .single()
7
8 if (error) {
9 console.error('Insert error:', error)
10 return null
11 }
12 return data
13}
14
15async function toggleTodo(id: number, isComplete: boolean) {
16 const { error } = await supabase
17 .from('todos')
18 .update({ is_complete: !isComplete })
19 .eq('id', id)
20
21 if (error) console.error('Update error:', error)
22}
23
24async function deleteTodo(id: number) {
25 const { error } = await supabase
26 .from('todos')
27 .delete()
28 .eq('id', id)
29
30 if (error) console.error('Delete error:', error)
31}

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.

typescript
1useEffect(() => {
2 // Initial fetch
3 fetchTodos()
4
5 // Subscribe to real-time changes
6 const channel = supabase
7 .channel('todos-changes')
8 .on(
9 'postgres_changes',
10 { event: '*', schema: 'public', table: 'todos' },
11 (payload) => {
12 if (payload.eventType === 'INSERT') {
13 setTodos((prev) => [payload.new as Todo, ...prev])
14 }
15 if (payload.eventType === 'UPDATE') {
16 setTodos((prev) =>
17 prev.map((t) => (t.id === payload.new.id ? payload.new as Todo : t))
18 )
19 }
20 if (payload.eventType === 'DELETE') {
21 setTodos((prev) => prev.filter((t) => t.id !== payload.old.id))
22 }
23 }
24 )
25 .subscribe()
26
27 // Cleanup subscription on unmount
28 return () => {
29 supabase.removeChannel(channel)
30 }
31}, [])

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

Complete working example

src/components/TodoApp.tsx
1import { useEffect, useState } from 'react'
2import { supabase } from '../lib/supabase'
3
4interface Todo {
5 id: number
6 task: string
7 is_complete: boolean
8 user_id: string
9}
10
11export function TodoApp({ userId }: { userId: string }) {
12 const [todos, setTodos] = useState<Todo[]>([])
13 const [newTask, setNewTask] = useState('')
14 const [loading, setLoading] = useState(true)
15
16 async function fetchTodos() {
17 const { data, error } = await supabase
18 .from('todos')
19 .select('*')
20 .order('created_at', { ascending: false })
21 if (!error) setTodos(data || [])
22 setLoading(false)
23 }
24
25 async function addTodo(e: React.FormEvent) {
26 e.preventDefault()
27 if (!newTask.trim()) return
28 await supabase
29 .from('todos')
30 .insert({ task: newTask, user_id: userId })
31 setNewTask('')
32 }
33
34 async function toggleTodo(todo: Todo) {
35 await supabase
36 .from('todos')
37 .update({ is_complete: !todo.is_complete })
38 .eq('id', todo.id)
39 }
40
41 async function deleteTodo(id: number) {
42 await supabase.from('todos').delete().eq('id', id)
43 }
44
45 useEffect(() => {
46 fetchTodos()
47
48 const channel = supabase
49 .channel('todos-realtime')
50 .on('postgres_changes',
51 { event: '*', schema: 'public', table: 'todos' },
52 (payload) => {
53 if (payload.eventType === 'INSERT') {
54 setTodos((prev) => [payload.new as Todo, ...prev])
55 } else if (payload.eventType === 'UPDATE') {
56 setTodos((prev) =>
57 prev.map((t) => t.id === payload.new.id ? payload.new as Todo : t)
58 )
59 } else if (payload.eventType === 'DELETE') {
60 setTodos((prev) => prev.filter((t) => t.id !== payload.old.id))
61 }
62 }
63 )
64 .subscribe()
65
66 return () => { supabase.removeChannel(channel) }
67 }, [])
68
69 if (loading) return <p>Loading todos...</p>
70
71 return (
72 <div>
73 <form onSubmit={addTodo}>
74 <input
75 value={newTask}
76 onChange={(e) => setNewTask(e.target.value)}
77 placeholder="Add a new task"
78 />
79 <button type="submit">Add</button>
80 </form>
81 <ul>
82 {todos.map((todo) => (
83 <li key={todo.id}>
84 <input
85 type="checkbox"
86 checked={todo.is_complete}
87 onChange={() => toggleTodo(todo)}
88 />
89 <span style={{
90 textDecoration: todo.is_complete ? 'line-through' : 'none'
91 }}>{todo.task}</span>
92 <button onClick={() => deleteTodo(todo.id)}>Delete</button>
93 </li>
94 ))}
95 </ul>
96 </div>
97 )
98}

Common mistakes when using Supabase with React

Why it's a problem: Creating the Supabase client inside a component, causing re-initialization on every render

How to avoid: 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.

Why it's a problem: Not cleaning up real-time subscriptions on component unmount, causing memory leaks

How to avoid: 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.

Why it's a problem: Forgetting to add the table to the realtime publication before subscribing

How to avoid: Run ALTER PUBLICATION supabase_realtime ADD TABLE your_table; in the SQL Editor. Without this, the real-time subscription will not receive any events.

Why it's a problem: Not including user_id when inserting records, causing RLS to reject the insert silently

How to avoid: 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

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I am building a React app with Supabase. Help me set up the Supabase client, create a todo table with RLS policies for per-user access, build a TodoList component with full CRUD operations, and add real-time subscriptions so the list updates live.

Supabase Prompt

Create a React TodoApp component that uses the Supabase JS client to fetch, add, toggle, and delete todos. Include real-time subscriptions for INSERT, UPDATE, and DELETE events. Show the complete component with TypeScript types and proper useEffect cleanup.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.