Supabase Patterns — Backend Conventions
Standard patterns for Supabase backends in Expo/iOS apps.
Client Setup
typescript
// 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.
Type Generation
bash
# 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
Type Helper Pattern
typescript
// 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.
Authentication Patterns
Auth State Management
typescript
// 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 };
}
Auth Methods
typescript
// 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;
}
Auth Rules
- •Always handle both
signUpandsignInerrors distinctly - •Use
signInWithIdTokenfor Apple Sign-In (required for iOS App Store) - •Set up email confirmation for production
- •Store nothing auth-related in AsyncStorage manually — Supabase handles it
- •Always check
sessionbefore making authenticated requests
Migration Patterns
Migration File Naming
code
supabase/migrations/YYYYMMDDHHMMSS_descriptive_name.sql
Example: 20250214120000_create_profiles_table.sql
Standard Table Template
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();
Migration Rules
- •One concern per migration (don't mix table creation with seed data)
- •Always enable RLS immediately after creating a table
- •Always add
created_atandupdated_atto every table - •Use
uuidfor primary keys, referenceauth.users(id)for user-owned data - •Use
on delete cascadethoughtfully — understand what it destroys - •Test migrations locally before pushing:
npx supabase db reset
Row Level Security (RLS)
Standard Policy Templates
sql
-- 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);
RLS Rules
- •Every table MUST have RLS enabled — no exceptions
- •Default deny: if no policy matches, access is denied
- •Always use
auth.uid()— never trust client-supplied user IDs - •
using= filter for SELECT/UPDATE/DELETE (which rows can be read) - •
with check= validation for INSERT/UPDATE (which rows can be written) - •Test policies by switching between different user JWTs locally
Service Layer Pattern
typescript
// 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;
}
Service Rules
- •Every function returns typed data or throws
- •Always
.select()after.insert()/.update()to get the result - •Use
.single()when expecting exactly one row - •Never expose raw Supabase client in components
- •Group queries by table/feature in separate files
Real-time Subscriptions
typescript
// 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]);
}
Edge Functions
typescript
// supabase/functions/process-image/index.ts
import { serve } from 'https://deno.land/std@0.168.0/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' },
});
}
});
Storage Patterns
typescript
// 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;
}
Environment Variables
bash
# .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.