Supabase backend patterns for Expo apps: authentication, Row Level Security, migrations, edge functions, real-time subscriptions, and type generation. Use when setting up Supabase, writing migrations, configuring RLS policies, creating edge functions, or debugging auth.
Standard patterns for Supabase backends in Expo/iOS apps.
// src/lib/supabase.ts
import 'react-native-url-polyfill/auto';
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Database } from '@/types/database';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false, // Important for React Native
},
});
Critical: Always use Database generic for type safety. Never use untyped client.
# Generate types from remote database
npx supabase gen types typescript --project-id YOUR_PROJECT_ID > src/types/database.ts
# Generate from local database
npx supabase gen types typescript --local > src/types/database.ts
// src/types/database.ts (append after generation)
export type Tables<T extends keyof Database['public']['Tables']> =
Database['public']['Tables'][T]['Row'];
export type Insertable<T extends keyof Database['public']['Tables']> =
Database['public']['Tables'][T]['Insert'];
export type Updatable<T extends keyof Database['public']['Tables']> =
Database['public']['Tables'][T]['Update'];
// Usage: Tables<'profiles'>, Insertable<'todos'>, etc.
Re-generate types after every migration. This is non-negotiable.
// src/hooks/useAuth.ts
import { useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { useAuthStore } from '@/stores/useAuthStore';
export function useAuth() {
const { session, setSession, isLoading, setIsLoading } = useAuthStore();
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setIsLoading(false);
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
}
);
return () => subscription.unsubscribe();
}, []);
return { session, isLoading, user: session?.user ?? null };
}
// src/services/api/auth.ts
import { supabase } from '@/lib/supabase';
export async function signInWithEmail(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return data;
}
export async function signUpWithEmail(email: string, password: string) {
const { data, error } = await supabase.auth.signUp({
email,
password,
});
if (error) throw error;
return data;
}
export async function signInWithApple() {
// Apple Sign-In for iOS
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: 'id_token_from_apple',
});
if (error) throw error;
return data;
}
export async function signOut() {
const { error } = await supabase.auth.signOut();
if (error) throw error;
}
signUp and signIn errors distinctlysignInWithIdToken for Apple Sign-In (required for iOS App Store)session before making authenticated requestssupabase/migrations/YYYYMMDDHHMMSS_descriptive_name.sql
Example: 20250214120000_create_profiles_table.sql
-- supabase/migrations/20250214120000_create_profiles.sql
create table public.profiles (
id uuid references auth.users(id) on delete cascade primary key,
email text not null,
full_name text,
avatar_url text,
created_at timestamptz default now() not null,
updated_at timestamptz default now() not null
);
-- Enable RLS
alter table public.profiles enable row level security;
-- Create updated_at trigger
create or replace function public.handle_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
create trigger set_updated_at
before update on public.profiles
for each row execute function public.handle_updated_at();
-- Auto-create profile on signup
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, email, full_name)
values (
new.id,
new.email,
coalesce(new.raw_user_meta_data->>'full_name', '')
);
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
created_at and updated_at to every tableuuid for primary keys, reference auth.users(id) for user-owned dataon delete cascade thoughtfully — understand what it destroysnpx supabase db reset-- Users can read their own data
create policy "Users can read own data"
on public.profiles for select
using (auth.uid() = id);
-- Users can update their own data
create policy "Users can update own data"
on public.profiles for update
using (auth.uid() = id)
with check (auth.uid() = id);
-- Users can insert their own data
create policy "Users can insert own data"
on public.todos for insert
with check (auth.uid() = user_id);
-- Users can delete their own data
create policy "Users can delete own data"
on public.todos for delete
using (auth.uid() = user_id);
-- Public read access (e.g., for a blog)
create policy "Public read access"
on public.posts for select
using (published = true);
auth.uid() — never trust client-supplied user IDsusing = filter for SELECT/UPDATE/DELETE (which rows can be read)with check = validation for INSERT/UPDATE (which rows can be written)// src/services/api/todos.ts
import { supabase } from '@/lib/supabase';
import { Tables, Insertable } from '@/types/database';
export async function getTodos(): Promise<Tables<'todos'>[]> {
const { data, error } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return data;
}
export async function createTodo(todo: Insertable<'todos'>): Promise<Tables<'todos'>> {
const { data, error } = await supabase
.from('todos')
.insert(todo)
.select()
.single();
if (error) throw error;
return data;
}
export async function updateTodo(
id: string,
updates: Partial<Insertable<'todos'>>
): Promise<Tables<'todos'>> {
const { data, error } = await supabase
.from('todos')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
}
export async function deleteTodo(id: string): Promise<void> {
const { error } = await supabase.from('todos').delete().eq('id', id);
if (error) throw error;
}
.select() after .insert() / .update() to get the result.single() when expecting exactly one row// src/hooks/useRealtimeTodos.ts
import { useEffect } from 'react';
import { supabase } from '@/lib/supabase';
export function useRealtimeTodos(onUpdate: () => void) {
useEffect(() => {
const channel = supabase
.channel('todos-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'todos' },
() => onUpdate()
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [onUpdate]);
}
// supabase/functions/process-image/index.ts
import { serve } from 'https://deno.land/[email protected]/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
serve(async (req) => {
try {
// Create authenticated client from request
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: { headers: { Authorization: req.headers.get('Authorization')! } },
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });
// Your logic here
const body = await req.json();
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});
// Upload image
async function uploadAvatar(userId: string, file: ImagePickerAsset) {
const fileExt = file.uri.split('.').pop();
const filePath = `${userId}/avatar.${fileExt}`;
const formData = new FormData();
formData.append('file', {
uri: file.uri,
name: `avatar.${fileExt}`,
type: `image/${fileExt}`,
} as any);
const { error } = await supabase.storage
.from('avatars')
.upload(filePath, formData, { upsert: true });
if (error) throw error;
const { data } = supabase.storage.from('avatars').getPublicUrl(filePath);
return data.publicUrl;
}
# .env.local (never commit)
EXPO_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhb...
# Access in code
process.env.EXPO_PUBLIC_SUPABASE_URL
Rule: Only EXPO_PUBLIC_ prefixed vars are available in client code.
Secrets go in Supabase Edge Function environment, not in the app.