SSiddiq.in
Postgres + a very good SDK

Supabase is Postgres in a friendly wrapper. Which means the schema you design is the app you get.

The friendly wrapper saves weeks. The Postgres underneath means the design decisions are permanent. This page is how we make those decisions — schemas we won't regret, RLS policies that actually hold, and the small list of things we deliberately don't do in Supabase.

Schema design · the decisions you can't take back

Six choices we make in week one.

UUIDs, not serial ids

Bigger, uglier, but shareable, unguessable, and safe to generate on the client. Serial keys leak business volume.

`created_at`, `updated_at` on everything

Non-negotiable. `updated_at` is a trigger, not an app-layer concern.

Soft-delete only where audit matters

Otherwise it's a foot-gun. Every query needs `WHERE deleted_at IS NULL` — one miss leaks a deleted user.

Money as `numeric`, not `float`

Ask any fintech engineer why. Or don't and find out expensively later.

Enum via check constraint or lookup table

Postgres enums are painful to alter. Lookup tables cost one join and give you a UI-editable list.

Foreign keys always ON DELETE explicit

Never let the default happen. CASCADE, SET NULL, RESTRICT — pick, document, done.

RLS · the one part everyone gets wrong

A working mental model for Row Level Security.

The wrong mental model

"RLS handles security so I can call `select *` from the client." → Now every table you forget to enable RLS on is a public data leak. And the service-role key you sprinkled into a Next.js API route bypasses everything.

The right mental model

"RLS is a defence-in-depth layer. My API still authenticates and authorises. RLS is the seatbelt when the API has a bug." → Every table has RLS on by default. Policies are readable in one sentence: "a user can read a row where user_id = auth.uid()".

-- The four policies every user-facing table needs
alter table invoices enable row level security;

create policy "own_read"   on invoices for select using (user_id = auth.uid());
create policy "own_insert" on invoices for insert with check (user_id = auth.uid());
create policy "own_update" on invoices for update using (user_id = auth.uid());
create policy "own_delete" on invoices for delete using (user_id = auth.uid());

-- Roles never stored on profiles. Separate table + security-definer function.
-- See our user-roles pattern for the full setup.
Honest limits

The three things we do outside Supabase.

Heavy background jobs

Edge Functions time out. For anything > 30s (video processing, LLM batch, exports), we run a dedicated worker on Fly / Railway with pg-boss reading a job queue in Postgres.

Complex analytics

Long-running aggregates hurt live query performance. We stream to a read replica or a warehouse (BigQuery / DuckDB) and query there.

Public write endpoints under attack

Contact forms, waitlists — spam gets ugly. We front them with Cloudflare Turnstile + rate limits before they touch Supabase.

Supabase FAQ

Supabase saves you 3–6 weeks of auth, RLS wiring, storage, and realtime plumbing. For 90% of SaaS starts, that time is worth more than the abstraction it costs. Roll your own when compliance forbids managed services or when you need Postgres extensions Supabase doesn't allow.

RLS policy patterns

Six patterns that cover 90% of RLS work — including the one people get wrong.

Own rows
`USING (auth.uid() = user_id)`. Simplest, safest. Applies to profiles, personal todos, private drafts.
Organisation membership
`USING (org_id IN (SELECT org_id FROM memberships WHERE user_id = auth.uid()))`. Wrap in a SECURITY DEFINER function to avoid infinite recursion.
Public read, owner write
Two policies: `FOR SELECT USING (true)` and `FOR UPDATE USING (auth.uid() = author_id)`. Blog posts, listings, comments.
Admin bypass
Never on `user_metadata` (client-controllable). Check a `has_role()` SECURITY DEFINER function that reads a separate `user_roles` table.
Soft-delete filter
`USING (deleted_at IS NULL)`. Pair with a partial unique index. Restores are UPDATEs, not INSERTs.
Invitation / pending access
`USING (id IN (SELECT resource_id FROM invitations WHERE email = auth.jwt()->>'email' AND accepted_at IS NULL))`. Grants access before the user joins the org.

Get a schema you won't rewrite in 18 months.

Book a schema review
Right tech, honest advice

Not sure if this is the right stack for you?

Tell us your goals — we'll recommend the right technology honestly, even if it isn't this one.

  • Personal reply from Siddiq within 24 hours
  • Honest advice — no pushy sales, no fake urgency
  • Clear next steps, timeline, and estimate

We only use your details to reply to this enquiry. Privacy.