Back

How I build with Supabase

I use Supabase as my backend for everything I build these days. Here's a peek into my workflow and how I build personal software with it.

Getting started

  1. I always use the same tools: Supabase, Next.js, server components, nuqs, React Query when necessary, etc. This helps keep context (instructions) consistent and building off each other.
  2. I always start project myself, using Supabase UI. More specifically, I init every project manually using npx create-next-app -e with-supabase. I find this step is important as it reduces variability and ensures consistent project structure.
  3. I use Claude Code mostly, with Cursor for the hand-rolled bits. More on AI below.

Supabase

  1. I use declarative schemas obsessively.
  2. I number migration files with prefixes to ensure order and avoid conflicts
  3. I always start with 00_extensions
  4. I always start projects with a single migration file. I might work on a project locally for weeks, but once I'm ready to make it live, there's just a single migration file. I'll delete any existing migration files, run supabase db reset, then supabase db diff -f init to create the new init migration. I just learned about squashing migrations with supabase migration squash today, but I haven't used it yet.
  5. Everything starts locally, then I move to hosted when I'm basically at a v1.
  6. The Dashboard is essentially "read-only". I make a change in code, create a new migration, supabase migration up and then verify that it works. I don't do any creating in the Dashboard.

Seeding data

  • I don't do any seeding with SQL (via a seed.sql file), though that's an out-of-the-box solution for Supabase.
  • Instead, I seed with Typescript. At any time, I can run npm run seed:<local | prod> and all of my data will be torn down and recreated. All users, all tables, everything.
  • I have AI create and update these scripts so I can get a proper environment working quickly

Testing RLS Policies

  • Like seeding, I test my RLS policies with Typescript
  • I create a Vitest setup in a /tests/ directory and create one test file per table.
  • Every table has a seed file to create a row to test
  • The tests use the Supabase secret key to create users, populate tables, simulate access control, and clean up after itself
  • The flow looks something like: create user -> seed table -> signInAs(user) -> try CRUD actions (e.g., "Alice can't update Bob's profile", "Bob can update his own profile", "Anyone can select posts", etc)
  • You still need to think carefully about who can access what, but this setup lets you test in English which is really quick

Env vars

  • start with .env.example
  • duplicate this, create a .env.local
  • duplicate this, create a .env.prod
  • small thing, but doing this reduces the likelihood of annoying issues with mis-configured env vars

UX

  • always store state in the url
  • every possible state should be shareable via a url
  • inputs should get focus when they appear (e.g., when a modal/popover is opened)
  • Esc key should work everywhere

AI

  • I write very little code by hand these days. On small codebases like these personal projects, I don't have issues with context and Claude Code is able to handle it exactly like I want it
  • I try to not rely on AI whenever possible. Ex: I'll use components from the UI Library any time I can
  • I haven't found obsessing over CLAUDE.md files to be super useful. Maybe I'm doing it wrong, but sometimes my instructions just get ignored. I always watch what the AI is doing and tell it to do it the way I like (store state in the url, for example, is very commonly ignored until that pattern is established in actual files)
  • Existing patterns in the codebase are worth more than instructions
Follow along →@saltcod