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