- Accordion
- Action Sheet
- Alert Dialog
- Alert
- Audio Player
- Audio Recorder
- Audio Waveform
- Avatar
- AvoidKeyboard
- Badge
- BottomSheet
- Button
- Camera Preview
- Camera
- Card
- Carousel
- Checkbox
- Collapsible
- Color Picker
- Combobox
- Date Picker
- File Picker
- Gallery
- Hello Wave
- Icon
- Image
- Input OTP
- Input
- Link
- MediaPicker
- Mode Toggle
- Onboarding
- ParallaxScrollView
- Picker
- Popover
- Progress
- Radio
- ScrollView
- SearchBar
- Separator
- Share
- Sheet
- Skeleton
- Spinner
- Switch
- Table
- Tabs
- Text
- Toast
- Toggle
- Video
- View
Your Postgres schema lives in supabase/migrations/ as plain SQL, applied in
filename order and checked into git. There is no schema DSL and no ORM — the
migration is the source of truth, and lib/database.types.ts is generated from
it.
The workflow
Write a migration
pnpm dlx supabase migration new add_projects
Creates supabase/migrations/<timestamp>_add_projects.sql. Write your DDL in
it, including enable row level security and the policies.
Apply it
pnpm db:push # to your linked project npm run db:reset # or: rebuild the local stack from scratch + seed.sql
Regenerate the types
pnpm db:types
Commit the migration and the types together
The shipped .github/workflows/ci.yml has a job that rebuilds the types from
your migrations and fails if the checked-in file differs, so this is enforced
rather than remembered.
If you changed the schema by clicking around in Studio, npx supabase db diff -f add_projects (aliased to npm run db:diff) writes the difference out as a
migration instead of leaving your local and remote schemas divergent.
Typed queries
lib/database.types.ts is passed to createClient as a generic, which is what
makes everything downstream typed:
export const supabase = createClient<Database>(url, key, {/* … */});From there, column names, filters and return types are all checked:
const { data } = await supabase
.from('tasks')
.select('id, text, is_complete')
.eq('is_complete', false);
// data: { id: string; text: string; is_complete: boolean }[] | nullThe starters also export the row types by name, which is usually what you want in a component signature:
export type Tables<T extends keyof PublicSchema['Tables']> =
PublicSchema['Tables'][T]['Row'];
export type Profile = Tables<'profiles'>;
export type Task = Tables<'tasks'>;CI has no database to generate it from, and neither does a fresh clone. Commit it with every migration.
Row level security
RLS is the whole security model. The publishable key is compiled into your app bundle, so anyone with the app has it — your policies are the only thing deciding what that key can do.
Two rules cover most of it:
- Enable RLS on every table in
public. A new table has it off. Adding policies to a table without it does nothing. - Never trust a column the client supplies. Verify it with
with check.
The anatomy of a policy
create policy "Users can update their own tasks"
on public.tasks for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);| Clause | What it decides |
|---|---|
for update | Which operation. Separate policies for select, insert, delete. |
to authenticated | Which role. anon is an unauthenticated caller. |
using | Which existing rows are visible to this operation |
with check | What the row is allowed to look like after the write |
using without with check on an update lets a user take a row they own and
reassign it to somebody else. Both clauses, every time.
auth.uid() reads the id out of the caller's JWT. It is null for an
anonymous caller, so every policy above fails closed.
Insert policies only have with check
There is no existing row to test, so this is the one that stops a client claiming ownership it does not have:
create policy "Users can create their own tasks"
on public.tasks for insert
to authenticated
with check (auth.uid() = user_id);The client still sends user_id — see hooks/useTasks.ts — but sending
somebody else's is rejected by the database.
The no-auth starter is different
Its policies are using (true) for the anon role, which is correct for a
public demo and wrong for real data:
create policy "Anyone can read tasks"
on public.tasks for select
to anon, authenticated
using (true);Anyone who extracts the publishable key from your app can do exactly what these policies allow, from curl, forever. Tighten them or move to the auth starter before you put anything real behind them.
Indexes follow policies
Every policy that filters on a column makes every query filter on that column. Without an index, each one is a sequential scan:
create index tasks_user_id_created_at_idx
on public.tasks (user_id, created_at desc);This is the most common performance problem in an RLS-heavy schema, and it does not show up until the table is large.
Testing a policy
The honest test is two accounts. Sign in as one, create a row, sign in as the other, and confirm it is not there. Locally you can also ask Postgres directly:
set local role authenticated;
set local request.jwt.claims = '{"sub": "<some-user-id>"}';
select * from public.tasks; -- only that user's rows
reset role;Triggers
The auth starter creates a profile row the moment a user exists, rather than from the client:
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();Doing it in the database means it cannot be skipped by a user who closes the app mid-sign-up, and the app never has to handle "signed in but no profile yet" as a permanent state.
handle_new_user runs as its owner so it can write to a table the caller has
no insert policy for. Without set search_path = '', a schema earlier in the
path could shadow public.profiles and capture the insert. Pin it on every
definer function you write.
Realtime needs two lines
alter publication supabase_realtime add table public.tasks;
alter table public.tasks replica identity full;The publication is what makes changes broadcast at all — without it a
subscription connects and then stays silent. replica identity full is what
puts the whole row in UPDATE and DELETE payloads instead of just the primary
key. See realtime.
Migrations in production
supabase db push applies anything not yet recorded in the remote migration
history. It does not roll back, and Postgres DDL that rewrites a large table
takes a lock — so the usual advice applies: add columns as nullable, backfill
separately, and drop nothing until nothing reads it.
The shipped CI workflow pushes migrations on merge to main. See
deployment.