13 KiB
supabase_test — Supabase + .NET + React boilerplate
Reusable template for a per-user CRUD app backed by Supabase Auth + Postgres. The stack:
- React (Vite + TypeScript) SPA in the browser — uses
@supabase/supabase-jsonly for authentication. - ASP.NET Core 9 Web API — validates Supabase-issued JWTs against the project's JWKS and owns all database access.
- Supabase — hosted auth + Postgres.
The .NET backend is the sole gateway to data. The browser never talks to PostgREST. Row-level security stays off; the backend enforces ownership in SQL.
Architecture at a glance
[React SPA] ──(bearer JWT)──▶ [ASP.NET Core Web API] ──(Npgsql / EF Core)──▶ [Supabase Postgres]
│ ▲
└──(auth handshake with supabase-js)─┘
└────(JWKS fetch via OpenID discovery)─────┘
- React reads its session with
supabase.auth.getSession()and attachesAuthorization: Bearer <access_token>to every/api/todos*request. - The .NET backend validates the JWT asymmetrically against
https://<ref>.supabase.co/auth/v1/.well-known/openid-configuration— no shared secret. Thesubclaim (a UUID) becomesuser_idon every query. - Every mutation is scoped by
WHERE user_id = @currentUser. Non-owned rows return 404 (not 403) — no existence leak. - Backend connects to Postgres via Supabase's session pooler (
aws-<n>-<region>.pooler.supabase.com:5432). The direct DB host is IPv6-only for new Supabase projects and unreachable from IPv4-only environments.
Repo layout
backend/ ASP.NET Core 9 Web API
├── Program.cs DbContext, JWT auth, controllers, CORS (dev-only), SPA fallback, /health
├── Controllers/TodosController.cs GET / POST / PATCH / DELETE, [Authorize]-gated
├── Data/AppDbContext.cs EF Core context (single Todo entity)
├── Models/Todo.cs
├── Dtos/ Wire DTOs (never expose user_id)
├── appsettings.json Shared, non-secret defaults
├── appsettings.Development.example.json Template with placeholders
└── appsettings.Development.json REAL values, gitignored
frontend/ Vite + React + TypeScript SPA
├── src/lib/supabase.ts createClient(url, publishable_key)
├── src/lib/api.ts Authed fetch wrapper (attaches Bearer)
├── src/auth/AuthProvider.tsx Session state via onAuthStateChange
├── src/auth/SignIn.tsx Email + password sign-in / sign-up
├── src/todos/TodoList.tsx List + add + toggle + delete
├── src/App.tsx / main.tsx Route between SignIn and TodoList
├── .env.example Template with placeholders
└── .env.local REAL values, gitignored
migrations/001_create_todos.sql Plain SQL applied once against Supabase Postgres
docs/superpowers/specs/ Original design spec
docs/superpowers/plans/ Implementation plan
Dockerfile / docker-compose.yml Coolify-ready single-image packaging
.dockerignore Keeps host bin/, obj/, node_modules/ out of the build context
deploy.json Coolify per-app config (gitignored — holds runtime secrets)
Configuring for a new Supabase project
Six values change per Supabase project. Everything else is project-agnostic — same schema, same auth flow, same code.
| Value | Where to get it (Supabase dashboard) | Where it goes |
|---|---|---|
Project URL (e.g. https://<ref>.supabase.co) |
Project Settings → API → Project URL | backend/appsettings.Development.json (Supabase:MetadataAddress, Supabase:Issuer) and frontend/.env.local (VITE_SUPABASE_URL) |
| Publishable (anon) key | Project Settings → API → Project API Keys → publishable |
frontend/.env.local (VITE_SUPABASE_PUBLISHABLE_KEY) |
| Secret (service-role) key | Project Settings → API → Project API Keys → secret |
NOT used by the app; only useful for admin-API E2E tests |
| Database password | Set once during project creation; can be reset | backend/appsettings.Development.json (part of ConnectionStrings:Postgres) |
| Session pooler hostname | Project Settings → Database → Connection string → Session pooler tab (port 5432) | backend/appsettings.Development.json (host + username in ConnectionStrings:Postgres) |
| Pooler username | Same tab; format is postgres.<project-ref> — always postgres + dot + project ref |
Same connection string |
Concrete steps for a new project
- Copy the templates and fill them in:
Substitute the six values above.
cp backend/appsettings.Development.example.json backend/appsettings.Development.json cp frontend/.env.example frontend/.env.local - Apply the migration against the new Supabase project:
(Or paste the SQL into the Supabase SQL Editor.)
PGPASSWORD='<db-password>' psql \ "host=aws-<n>-<region>.pooler.supabase.com port=5432 dbname=postgres user=postgres.<project-ref> sslmode=require" \ -f migrations/001_create_todos.sql - Disable email confirmation for dev: Supabase dashboard → Authentication → Providers → Email → toggle Confirm email OFF. Otherwise
supabase.auth.signUpwon't return a session and the SignIn UI hangs at "Working…" while a confirmation email is queued (and often silently rate-limited to 2–4/hour on the default SMTP). - Install and run:
cd frontend && npm install # Terminal 1 cd backend && dotnet run # http://localhost:5057 # Terminal 2 cd frontend && npm run dev # http://localhost:5173
Config surface — every knob
Backend (backend/appsettings.Development.json)
{
"ConnectionStrings": {
"Postgres": "Host=aws-<n>-<region>.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.<ref>;Password=<db-password>;SSL Mode=Require;Trust Server Certificate=true"
},
"Supabase": {
"MetadataAddress": "https://<ref>.supabase.co/auth/v1/.well-known/openid-configuration",
"Issuer": "https://<ref>.supabase.co/auth/v1",
"Audience": "authenticated"
}
}
Audience is always authenticated for user-issued JWTs — do not change unless you're validating service-role tokens (you shouldn't be, in this app).
The backend's port comes from backend/Properties/launchSettings.json (applicationUrl — default http://localhost:5057). If you move the backend to a different port, also update VITE_API_URL and the CORS WithOrigins(...) clause in Program.cs.
Frontend (frontend/.env.local)
VITE_SUPABASE_URL=https://<ref>.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
VITE_API_URL=http://localhost:5057
VITE_API_URL empty (or unset) means same-origin — used when the backend serves the built SPA from wwwroot/ in production (Dockerfile path). Set it explicitly during local dev when frontend and backend run on different ports.
Container / Coolify (docker-compose.yml)
Six env vars flow in from Coolify's env-var UI:
| Coolify env | Purpose |
|---|---|
VITE_SUPABASE_URL |
Baked into the client bundle at image-build time (compose build.args) |
VITE_SUPABASE_PUBLISHABLE_KEY |
Same |
DB_CONNECTION_STRING |
Runtime, mapped to ConnectionStrings__Postgres |
SUPABASE_METADATA_ADDRESS |
Runtime, mapped to Supabase__MetadataAddress |
SUPABASE_ISSUER |
Runtime, mapped to Supabase__Issuer |
| — | Supabase__Audience is hard-coded to authenticated in the compose |
The compose file also declares SERVICE_FQDN_APP_8080: /. Coolify substitutes this with the app's real domain and injects the matching Traefik routing labels — only if docker_compose_domains is set on the Coolify application. Skipping that field turns every request into a 404 (see the deploying-to-coolify-via-api skill for the fix).
Data model
Single table:
create table public.todos (
id bigserial primary key,
user_id uuid not null, -- Supabase auth.users.id (JWT sub claim)
title text not null check (length(title) between 1 and 500),
completed boolean not null default false,
created_at timestamptz not null default now()
);
create index todos_user_id_created_at_idx on public.todos (user_id, created_at desc);
No FK to auth.users — decouples from Supabase's internal schema and avoids permission headaches at migration time.
Extending the pattern
To add a new entity (e.g. notes), the same shape repeats:
- Add a table with
user_id uuid not null+ a(user_id, created_at desc)index. Ship asmigrations/00N_create_notes.sql. - Add
backend/Models/Note.cs,backend/Dtos/{NoteDto,CreateNoteRequest,UpdateNoteRequest}.cs. - Register the DbSet in
AppDbContextwithHasIndex(...)matching the SQL index name. - Add
NotesController— copyTodosControllerverbatim, swap entity/DTO names. Keep theCurrentUserId()+WHERE user_id == userIdpattern for every read/write; that's what enforces per-user isolation without RLS. - Frontend: an
api.notesobject withlist/create/setX/removealongside the existingapitodos block, plus aNotesListcomponent modelled onTodoList.
The critical invariant, everywhere: every query filters by userId = CurrentUserId(). Missing that on a single endpoint leaks other users' data.
Known gotchas (all bit us during initial build — documented so they don't bite you)
| Symptom | Cause | Fix |
|---|---|---|
connection to server at "db.<ref>.supabase.co" failed: Network is unreachable |
Direct DB host is IPv6-only on new Supabase projects; sandbox has no IPv6 egress | Use the session pooler (aws-<n>-<region>.pooler.supabase.com, port 5432, user postgres.<ref>) |
dotnet add package X restore fails with NU1202 |
CLI grabbed a newer major version incompatible with net9.0 |
Pin: dotnet add package X --version 9.* |
| Sign-up hangs / "email rate limit exceeded" | Email confirmation ON; Supabase default SMTP limits to 2–4/hour | Toggle Auth → Providers → Email → Confirm email OFF for dev; or wire real SMTP |
Sign-up rejects @example.com with email_address_invalid |
Supabase blocklists disposable domains on the ordinary signup endpoint | For E2E scripts, use the admin API (POST /auth/v1/admin/users with email_confirm:true) — see docs/superpowers/plans/2026-08-15-supabase-todo-app.md Task 8 |
Deploy on Coolify finishes green, live URL returns 404 page not found |
Missing / mis-shaped docker_compose_domains on the Coolify app |
See the deploying-to-coolify-via-api skill: PATCH the array-form after the first deploy loads the compose file |
Vite template's index.css styles fight your inline styles |
Scaffolded index.css ships opinionated marketing styles (large h1, fixed #root width) |
Replace with the minimal reset in frontend/src/index.css |
| Backend port 5000 collisions across dev sessions | Common default for other Linux services / stale processes | This project uses 5057 — check the same for any port you pick |
What NOT to change without thinking
- RLS off is a deliberate choice. If you enable RLS on
public.todos, you must also either (a) writeUSING (auth.uid() = user_id)policies, or (b) have the backend run as a role withBYPASSRLS. Otherwise the backend's own queries stop returning rows. - The DTO response shape deliberately omits
user_id. Adding it back exposes the caller's own id (harmless) but invites confusion. RequireHttpsMetadata = trueon the JwtBearer options. If you're testing against a non-HTTPS Supabase project (there is no such thing in practice), set to false — otherwise leave it. This prevents downgrade attacks on the JWKS fetch.MapInboundClaims = false— keeps the rawsubclaim instead of remapping toClaimTypes.NameIdentifier. If you flip this, update theCurrentUserId()helper.
Related skills / docs
docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md— the original design decisions and out-of-scope list.docs/superpowers/plans/2026-08-15-supabase-todo-app.md— the 17-task implementation plan, including manual E2E verification recipes for backend (curl) and frontend (Playwright).- Global skill:
creating-supabase-app-from-boilerplate— clones this repo and reconfigures it against a new Supabase project. - Global skill:
preparing-dotnet-react-app-for-coolify— the Dockerfile + compose pattern this repo already implements. - Global skill:
deploying-to-coolify-via-api— how the Coolify project + application were created; run again against a new project or copydeploy.jsonand change the values.