Ship the idiomatic Supabase variant

Start state: nothing but a free Supabase account. End state: a deployed SaaS where the database IS the backend — RLS policies as the API surface, triggers and RPCs instead of server code, request/response data flow. This is deliberately not a Convex clone.

1. Scaffold

pnpm new atlas --title "Atlas" --type webapp --db supabase
pnpm install

Expected: apps/atlas scaffolded from _template/supabase-app with the full Supabase feature pool (everything except activity, which is Convex-only). Note there is no convex dev step — the next-steps block tells you to create a project and fill env keys instead.

2. Create the project and run schema.sql

  • 1. Create a Supabase project (free tier works)
  • 2. Open its SQL editor and run the entire apps/atlas/supabase/schema.sql in one go — tables, RLS policies, triggers, and RPCs ship as one file

Expected: "Success. No rows returned". The whole backend now exists.

3. Env files

cp apps/atlas/.env.example apps/atlas/.env.local
# fill in from the project's settings → API:
#   VITE_SUPABASE_URL=https://<ref>.supabase.co
#   VITE_SUPABASE_ANON_KEY=<anon key>
The anon key is safe in the browser precisely because RLS decides what it can do. What you must never ship to the client is the service-role key.

4. Run locally

cd apps/atlas && pnpm dev

Sign up with an email + password. You are user number one — which matters, as the next step explains.

5. How SQL replaces server code

There is no hand-written REST layer. The client talks to PostgREST with the anon key, and three SQL mechanisms do what server code would:

  • RLS policies — every table's policies scope rows to auth.uid(); adding a policy IS adding an endpoint
  • Triggers — handle_new_user runs after insert on auth.users: it auto-provisions a profiles row, derives the display name from the signup metadata or the email's local part, and assigns the role — if the profiles table is empty, the new user becomes owner; everyone after is member. That is why the first person to sign up owns the app, with no setup screen and no server code.
  • RPCs — cross-row logic like role changes lives in SECURITY DEFINER functions (e.g. the owner role can never be granted or taken away); is_admin() is the shared helper policies call without recursing through RLS

The frontend idiom matches: useSupaQuery in src/lib/useQuery.ts fetches on mount, and you call refetch after your own mutations — request/response, no realtime subscription by default.

6. Edge Functions (only what your features need)

supabase functions deploy ai-chat
supabase secrets set ANTHROPIC_API_KEY=sk-ant-...
supabase functions deploy mcp --no-verify-jwt
supabase secrets set APP_URL=<your deployed frontend URL>

7. Deploy

cp apps/atlas/.env.local apps/atlas/.env.production   # same keys for the deployed build
cd ../.. && pnpm deploy atlas

pnpm deploy refuses to run a Supabase app without .env.production — that guard is the whole reason the file exists. Expected: the SPA builds with the production keys and lands on Cloudflare Pages (or --to any of the 6 providers).

What you built

  • A deployed SaaS whose entire authorization model is auditable in one schema.sql
  • Auto-provisioning signup where the first user becomes owner via a trigger, not an admin panel
  • A mental model: policy = endpoint, trigger = lifecycle hook, RPC = privileged operation, Edge Function = secret-holding server code