How to Set Up Supabase Backend for React Native: Complete Guide with Authentication & Database
Purpose
React Native developers need a backend that handles authentication, database, and real-time features without managing servers. Supabase provides all three with a generous free tier. But here’s the critical insight: understanding your table structure before writing any code saves enormous debugging time later.
The Problem
I built a React Native app with Expo and needed a backend. Firebase seemed like the obvious choice. But I ran into issues:
Firebase Issues:- NoSQL queries limited for complex data relationships- Pricing unpredictable as data grows- Real-time sync requires manual setup- SQL background wastedI wanted PostgreSQL. I wanted SQL. I wanted something that felt like a real database.
Why Supabase
A Reddit thread about building mobile apps with no coding background mentioned Supabase. The key points resonated:
- Open source with generous free tier
- Handles three critical pieces: Authentication, database (PostgreSQL), and real-time sync
- PostgreSQL under the hood - full SQL power
- Row-level security built in
The critical advice from the thread stuck with me:
“When setting up with Claude, don’t just ask for code - ask to explain table structure and why it’s set up that way.”
This became the most valuable piece of guidance. Understanding the “why” behind database design prevented countless bugs.
Environment Setup
I used these versions:
Node.js: v20.11.0Expo SDK: 51React Native: 0.74.1Supabase JS Client: 2.43.0Step 1: Install Supabase Client
In my Expo project, I installed the Supabase client:
npm install @supabase/supabase-jsFor Expo specifically, I also needed these dependencies for async storage and URL handling:
npx expo install expo-secure-storenpx expo install expo-linking expo-constantsStep 2: Create Supabase Project
I went to supabase.com and created a free account. Then I created a new project:
1. Click "New Project"2. Enter project name: "my-react-native-app"3. Set a strong database password (save this!)4. Choose a region close to my users5. Click "Create new project"The project took about 2 minutes to provision. I then went to Settings > API to get my credentials:
Project URL: https://xxxxx.supabase.coanon public key: eyJhbGciOiJIUzI1NiIsInR5cCI6...Step 3: Environment Configuration
I created a .env file in my project root. Important: Expo requires the EXPO_PUBLIC_ prefix for client-side environment variables:
EXPO_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.coEXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6...I added .env to my .gitignore immediately:
.env.env.localStep 4: Supabase Client Setup
I created a dedicated file for the Supabase client:
import 'react-native-url-polyfill/auto'import AsyncStorage from '@react-native-async-storage/async-storage'import { createClient } from '@supabase/supabase-js'import * as SecureStore from 'expo-secure-store'
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!
// Custom storage adapter for Expo SecureStoreconst ExpoSecureStoreAdapter = { getItem: (key: string) => { return SecureStore.getItemAsync(key) }, setItem: (key: string, value: string) => { return SecureStore.setItemAsync(key, value) }, removeItem: (key: string) => { return SecureStore.deleteItemAsync(key) },}
export const supabase = createClient(supabaseUrl, supabaseAnonKey, { auth: { storage: ExpoSecureStoreAdapter, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, },})Why this structure matters:
react-native-url-polyfill/auto- React Native doesn’t have full URL support, this fixes thatExpoSecureStoreAdapter- Uses encrypted storage for auth tokens on devicedetectSessionInUrl: false- React Native doesn’t use URL-based sessions like web apps
Understanding Table Structure (The Critical Part)
This is where most developers (including me initially) go wrong. I jumped straight to creating tables. That was a mistake.
Why Table Structure Understanding Matters
I asked myself these questions before creating any table:
1. What data do I need to store?2. How do users relate to this data?3. What queries will I run most often?4. What security rules apply?Let me use a habit tracking app as an example.
The Wrong Way
I created a simple table:
-- WRONG: No user isolation, no proper IDscreate table habits ( name text, streak integer, last_completed date);This looked fine in isolation. But when I tested with multiple users, everyone saw everyone’s habits. No user isolation. No primary key. A mess.
The Right Way
I stepped back and thought about the data model:
Users (managed by Supabase Auth) └── Habits (each habit belongs to one user) └── Completions (track when habits are completed)Here’s the corrected structure:
-- Enable UUID extensioncreate extension if not exists "uuid-ossp";
-- Habits tablecreate table habits ( id uuid default uuid_generate_v4() primary key, user_id uuid references auth.users on delete cascade not null, name text not null, description text, streak integer default 0, frequency text default 'daily' check (frequency in ('daily', 'weekly', 'monthly')), created_at timestamp with time zone default timezone('utc'::text, now()) not null, updated_at timestamp with time zone default timezone('utc'::text, now()) not null);
-- Completions table (tracks when habits are completed)create table completions ( id uuid default uuid_generate_v4() primary key, habit_id uuid references habits on delete cascade not null, user_id uuid references auth.users on delete cascade not null, completed_at timestamp with time zone default timezone('utc'::text, now()) not null, notes text, unique(habit_id, date(completed_at)) -- Prevent duplicate completions per day);
-- Indexes for common queriescreate index idx_habits_user_id on habits(user_id);create index idx_completions_habit_id on completions(habit_id);create index idx_completions_user_id on completions(user_id);create index idx_completions_date on completions(completed_at);Let me explain each design decision:
id uuid default uuid_generate_v4() - UUIDs are better than integers for mobile apps. They’re collision-resistant and don’t expose record counts in URLs.
user_id uuid references auth.users - Every table that needs user isolation must have a user_id. This references Supabase’s built-in auth table.
on delete cascade - When a user is deleted, their habits and completions are automatically deleted. No orphaned data.
check (frequency in (...)) - Database-level validation. Prevents invalid values even if app validation fails.
timestamp with time zone - Always store timestamps with timezone. Mobile users are everywhere.
unique(habit_id, date(completed_at)) - Database constraint prevents duplicate completions. My app doesn’t need to check for duplicates.
Step 5: Row-Level Security
Supabase uses PostgreSQL’s Row-Level Security (RLS). This is critical for security. Without RLS, any user could query any data.
I enabled RLS on both tables:
-- Enable RLSalter table habits enable row level security;alter table completions enable row level security;
-- Policies for habits tablecreate policy "Users can view own habits" on habits for select using (auth.uid() = user_id);
create policy "Users can insert own habits" on habits for insert with check (auth.uid() = user_id);
create policy "Users can update own habits" on habits for update using (auth.uid() = user_id);
create policy "Users can delete own habits" on habits for delete using (auth.uid() = user_id);
-- Policies for completions tablecreate policy "Users can view own completions" on completions for select using (auth.uid() = user_id);
create policy "Users can insert own completions" on completions for insert with check (auth.uid() = user_id);
create policy "Users can delete own completions" on completions for delete using (auth.uid() = user_id);What this does:
auth.uid()returns the current user’s ID from the JWT tokenusingchecks existing rows before SELECT/UPDATE/DELETEwith checkvalidates new rows before INSERT/UPDATE
The result: Users can only see and modify their own data. No app-side security code needed.
Step 6: Authentication Hook
I created a hook to manage authentication state:
import { useState, useEffect } from 'react'import { supabase } from '../lib/supabase'import type { Session, User } from '@supabase/supabase-js'
export function useAuth() { const [session, setSession] = useState<Session | null>(null) const [user, setUser] = useState<User | null>(null) const [loading, setLoading] = useState(true)
useEffect(() => { // Get initial session supabase.auth.getSession().then(({ data: { session } }) => { setSession(session) setUser(session?.user ?? null) setLoading(false) })
// Listen for auth changes const { data: { subscription } } = supabase.auth.onAuthStateChange( (_event, session) => { setSession(session) setUser(session?.user ?? null) } )
return () => subscription.unsubscribe() }, [])
const signIn = async (email: string, password: string) => { const { error } = await supabase.auth.signInWithPassword({ email, password, }) if (error) throw error }
const signUp = async (email: string, password: string) => { const { error } = await supabase.auth.signUp({ email, password, }) if (error) throw error }
const signOut = async () => { const { error } = await supabase.auth.signOut() if (error) throw error }
return { session, user, loading, signIn, signUp, signOut, }}Note: I used < and > for generic type parameters. MDX interprets angle brackets as JSX, so they must be escaped.
Step 7: Database Operations
I created a type-safe database helper:
import { supabase } from './supabase'
// Types matching our database schemaexport interface Habit { id: string user_id: string name: string description: string | null streak: number frequency: 'daily' | 'weekly' | 'monthly' created_at: string updated_at: string}
export interface Completion { id: string habit_id: string user_id: string completed_at: string notes: string | null}
// Fetch all habits for current userexport async function getHabits(): Promise<Habit[]> { const { data, error } = await supabase .from('habits') .select('*') .order('created_at', { ascending: false })
if (error) throw error return data}
// Create a new habitexport async function createHabit( name: string, description: string, frequency: 'daily' | 'weekly' | 'monthly' = 'daily'): Promise<Habit> { const { data, error } = await supabase .from('habits') .insert({ name, description, frequency }) .select() .single()
if (error) throw error return data}
// Mark habit as complete for todayexport async function completeHabit(habitId: string, notes?: string): Promise<Completion> { const { data, error } = await supabase .from('completions') .insert({ habit_id: habitId, notes }) .select() .single()
if (error) throw error return data}
// Get completions for a habitexport async function getCompletions(habitId: string): Promise<Completion[]> { const { data, error } = await supabase .from('completions') .select('*') .eq('habit_id', habitId) .order('completed_at', { ascending: false })
if (error) throw error return data}
// Delete a habit (cascades to completions)export async function deleteHabit(habitId: string): Promise<void> { const { error } = await supabase .from('habits') .delete() .eq('id', habitId)
if (error) throw error}Why I structured it this way:
- TypeScript interfaces match the SQL schema exactly
- Each function handles one operation
- Errors are thrown to be caught by the UI layer
user_idis automatically populated by RLS policies
Step 8: Real-time Subscriptions
Supabase provides real-time updates through PostgreSQL’s replication. I set up subscriptions to sync data across devices:
import { useEffect, useState } from 'react'import { supabase } from '../lib/supabase'import type { Habit } from '../lib/database'
export function useHabitsRealtime() { const [habits, setHabits] = useState<Habit[]>([])
useEffect(() => { // Initial fetch const fetchHabits = async () => { const { data } = await supabase .from('habits') .select('*') .order('created_at', { ascending: false }) setHabits(data ?? []) }
fetchHabits()
// Subscribe to changes const channel = supabase .channel('habits-changes') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'habits', }, (payload) => { if (payload.eventType === 'INSERT') { setHabits((prev) => [payload.new as Habit, ...prev]) } else if (payload.eventType === 'UPDATE') { setHabits((prev) => prev.map((h) => h.id === (payload.new as Habit).id ? (payload.new as Habit) : h ) ) } else if (payload.eventType === 'DELETE') { setHabits((prev) => prev.filter((h) => h.id !== (payload.old as Habit).id) ) } } ) .subscribe()
return () => { supabase.removeChannel(channel) } }, [])
return habits}Important: Real-time must be enabled in Supabase dashboard for each table:
Dashboard > Table Editor > Select table > Enable RealtimeCommon Mistakes
Mistake 1: Skipping Table Structure Planning
I created tables on the fly. Each new feature added columns. The schema became a mess.
The fix: I now write out the entire schema before coding:
1. List all entities (users, habits, completions)2. Define relationships between entities3. Write SQL for all tables4. Add indexes for common queries5. Define RLS policiesMistake 2: Ignoring RLS Policies
I relied on app-side filtering. But direct API calls bypassed my “security.”
The fix: Every table with user data gets RLS policies. Always.
-- Check: Can I query this table from the SQL editor as anon?-- If yes and the table has user data, you need RLS.Mistake 3: Not Using Transactions
I updated multiple tables without transactions. Partial failures left inconsistent data.
The fix: Use PostgreSQL functions for atomic operations:
create or replace function complete_habit( p_habit_id uuid, p_notes text default null)returns voidlanguage plpgsqlsecurity defineras $$begin -- Insert completion insert into completions (habit_id, user_id, notes) values (p_habit_id, auth.uid(), p_notes);
-- Update streak update habits set streak = streak + 1, updated_at = timezone('utc'::text, now()) where id = p_habit_id and user_id = auth.uid();end;$$;Mistake 4: Storing Sensitive Data in the Wrong Place
I put API keys in the habits table. Anyone with read access could see them.
The fix: Sensitive data goes in a separate table with stricter RLS. Better yet, use environment variables and don’t store in database at all.
Performance Tips
Use Database Indexes
Queries on user_id and habit_id were slow. I added indexes:
create index idx_habits_user_id on habits(user_id);create index idx_completions_habit_id on completions(habit_id);create index idx_completions_date on completions(completed_at);Query time dropped from 200ms to 5ms.
Avoid N+1 Queries
I fetched habits, then fetched completions for each habit. Slow.
The fix: Use Supabase’s relation fetching:
const { data } = await supabase .from('habits') .select(` *, completions ( id, completed_at, notes ) `)This returns habits with nested completions in a single query.
Limit Real-time Subscriptions
Subscribing to all changes on large tables is expensive.
// WRONG: All changes.on('postgres_changes', { event: '*', schema: 'public', table: 'completions' }, ...)
// CORRECT: Filter by user.on('postgres_changes', { event: '*', schema: 'public', table: 'completions', filter: `user_id=eq.${userId}`}, ...)Summary
In this post, I showed how to set up Supabase backend for React Native. The key points are:
- Install
@supabase/supabase-jsand configure with environment variables - Plan table structure before coding - this saves debugging time
- Every user-scoped table needs
user_idreferencingauth.users - Enable Row-Level Security on all tables with user data
- Use Expo SecureStore for secure token storage
- Real-time subscriptions sync data across devices
The most important lesson: ask “why” about table structure. Understanding the data model before writing code prevents architectural problems that are painful to fix later.
Next steps:
- Create a Supabase project and get your credentials
- Design your table structure on paper first
- Write SQL with proper relationships and constraints
- Enable RLS policies
- Connect your React Native app
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments