Skip to content

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 wasted

I 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.0
Expo SDK: 51
React Native: 0.74.1
Supabase JS Client: 2.43.0

Step 1: Install Supabase Client

In my Expo project, I installed the Supabase client:

Terminal window
npm install @supabase/supabase-js

For Expo specifically, I also needed these dependencies for async storage and URL handling:

Terminal window
npx expo install expo-secure-store
npx expo install expo-linking expo-constants

Step 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 users
5. 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.co
anon 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:

.env
EXPO_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6...

I added .env to my .gitignore immediately:

.gitignore
.env
.env.local

Step 4: Supabase Client Setup

I created a dedicated file for the Supabase client:

src/lib/supabase.ts
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 SecureStore
const 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 that
  • ExpoSecureStoreAdapter - Uses encrypted storage for auth tokens on device
  • detectSessionInUrl: 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 IDs
create 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:

database/schema.sql
-- Enable UUID extension
create extension if not exists "uuid-ossp";
-- Habits table
create 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 queries
create 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:

database/rls.sql
-- Enable RLS
alter table habits enable row level security;
alter table completions enable row level security;
-- Policies for habits table
create 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 table
create 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 token
  • using checks existing rows before SELECT/UPDATE/DELETE
  • with check validates 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:

src/hooks/useAuth.ts
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:

src/lib/database.ts
import { supabase } from './supabase'
// Types matching our database schema
export 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 user
export 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 habit
export 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 today
export 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 habit
export 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_id is 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:

src/hooks/useHabitsRealtime.ts
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 Realtime

Common 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 entities
3. Write SQL for all tables
4. Add indexes for common queries
5. Define RLS policies

Mistake 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:

database/functions.sql
create or replace function complete_habit(
p_habit_id uuid,
p_notes text default null
)
returns void
language plpgsql
security definer
as $$
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-js and configure with environment variables
  • Plan table structure before coding - this saves debugging time
  • Every user-scoped table needs user_id referencing auth.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:

  1. Create a Supabase project and get your credentials
  2. Design your table structure on paper first
  3. Write SQL with proper relationships and constraints
  4. Enable RLS policies
  5. 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