Guide
Architecture: four layers, one rule
Multi-tenancy fails at the seams — the file where “just this once” the route wrote to the database directly, or the service that imported a SvelteKit helper and stopped being testable. This guide walks the layering we ship in our SvelteKit + Supabase Starter: thin routes, framework-free services, policy and payment seams, and a Supabase Row Level Security layer that refuses to trust the layer above it.
Scope first: this is a service-only foundation. It ships the domain logic, the RBAC and billing seams, the migrations, and the test suite — but deliberately no wired-up SvelteKit routes of its own (an initial-release scope choice, named below). The layering claims still hold: they describe how the shipped services and seams divide responsibilities, and the accompanying guides cover the enforcement in detail.
The four-layer picture
Everything in the codebase answers to a single downward-simplifying stack:
routes (+page.server.ts) thin: parse form → call service → fail/redirect
│
services (orgs/members/invites) domain logic, pure functions, Supabase client passed in
│
rbac.ts / billing/ policy + payment seams
│
supabase/client.ts Supabase client (service role for admin, user-scoped for RLS)Each arrow narrows responsibility. Routes handle the protocol; services own the domain; the seams own policy and payment; the client owns how Supabase is reached. Four rules keep the stack honest:
- Routes never touch the database directly. All writes go through a service so audit entries and permission checks cannot be skipped.
- Services are framework-free. They import nothing from SvelteKit — that is what makes them unit-testable against an in-memory fake Supabase client, with no runtime and no database.
- Every mutating service call re-derives authority from its arguments. Callers pass
actorRole, fetched fresh inside the same request. There is no trust in client-submitted role fields. - Errors carry machine codes.
AuthError,RbacError,InviteError,MemberError,OrgError,BillingError— mapped to HTTP responses in exactly one place per app, the route layer.
Two Supabase clients, two trust levels
The client module sets up two clients with sharply different powers. The service-role client, createServerClient, carries the server-only key and bypasses Row Level Security — it is reserved for admin writes like seat checks and audit entries. The user-scoped client, createUserClient(accessToken), is scoped to the current user’s JWT, so RLS applies to that user’s rows:
// service role — bypasses RLS, server only
const serverClient = createServerClient(SUPABASE_URL, PRIVATE_SUPABASE_SERVICE_ROLE_KEY);
// user-scoped — carries the user's JWT, RLS applies
const userClient = createUserClient(accessToken);The one rule here is absolute: never hand a service-role client to the browser. The PRIVATE_* environment variables are server-only by construction, and authentication itself lives in Supabase Auth (email+password, magic links, OAuth) — sessions live in Supabase, not in your database.
Concurrency without transactions
Two hot paths deliberately avoid read-then-write. Single-use invites do not check-then-claim; acceptance performs an UPDATE whose WHERE clause is the concurrency gate, then reads back the claimed row:
.eq('id', id).is('accepted_at', null).is('revoked_at', null)Zero rows claimed means someone got there first — safe under concurrent clicks without explicit transactions. Seat counting is a COUNT over memberships performed inside acceptInvite before the single-use claim, so a full org never claims an invite it cannot honor.
RBAC and RLS: defense in depth
Two independent layers enforce who may do what. Application code enforces RBAC on every mutating call — requirePermission() plus hierarchy checks in members.ts and invites.ts. Row Level Security enforces tenant isolation at the database level via is_org_member, get_org_role, and has_min_role helpers in the migration: a user can only read organizations they belong to, and only owners and admins can mutate membership and invite rows.
The layers verify each other. The unit suite proves the application business rules; a locally running Supabase (supabase start) proves the policies. A bug in one layer does not leak tenant data, because the other layer still refuses the write. The RBAC guide walks the matrix and hierarchy; the testing guide explains how each layer is proven.
The audit log is append-only by construction
audit_log.org_id and actor_user_id are plain columns with no foreign keys, so history survives member removal and (future) org deletion. Metadata is a JSON string; writers decide what goes in. The raw invite token is never audited. Most importantly there is no UPDATE or DELETE policy on audit_log — append-only by construction at both the schema layer and the application layer.
Environment variables
Four variables drive the starter, two shared with the browser and two server-only:
| Variable | Purpose |
|---|---|
PUBLIC_SUPABASE_URL | Your Supabase project URL (shared with the browser) |
PUBLIC_SUPABASE_ANON_KEY | Supabase anonymous key (shared with the browser) |
PRIVATE_SUPABASE_SERVICE_ROLE_KEY | Service-role key — server only, bypasses RLS |
MOCK_PLAN_SEATS | Seat limit for MockBillingAdapter (default 3) |
Swapping the billing provider
All payment knowledge sits behind the BillingAdapter interface (covered in the seat billing guide). Swapping the merchant of record changes one wiring function — no service or route changes. The consumer of the interface changes nothing when the provider does.
Deliberate limits
- Seats counted by the mock, not a subscriptions table.
acceptInvitecounts seats via the passed billing adapter; the shipped mock enforcesMOCK_PLAN_SEATS. A real adapter must read seats from asubscriptionstable updated by webhooks. - Invite email is a note, not enforcement. Invite links work for whoever holds them; the optional
emailfield is a human note. Email-enforced invites need email delivery, which needs an account — out of scope at the initial release. - RLS is shipped but not exercised by the unit suite. The policies live in the migration but are only fully exercised against a real Supabase instance (
supabase start). The unit suite tests application-level enforcement with a fake client. - Service-only scope. This is a foundation kit: it ships services, seams, migrations, and tests, but no wired-up SvelteKit routes of its own. That is a deliberate initial-release scope, not an omission — the pattern is intended to be wired by the buyer into their own route layer.
Production checklist
- Keep routes thin: parse the form, call a service, fail or redirect. No logic, no database access at the route layer.
- Keep services framework-free — they import nothing from SvelteKit, so the same functions test against an in-memory fake client.
- Have every mutating service call re-derive authority from its arguments: pass actorRole fetched fresh inside the same request.
- Carry machine error codes on every error and map them to HTTP responses in exactly one place per app.
- Never hand the service-role client to the browser; PRIVATE_* environment variables are server-only.
- Enforce RBAC in application code and tenant isolation in Supabase Row Level Security, and let the two layers verify each other.
- Make the audit log append-only by construction: no UPDATE or DELETE policy on the table, at the schema or the application layer.
All seven lines are implemented in the starter kit. The shipped documentation is the primary source: the same project that produces these guides also produces the kit’s own docs/architecture.md, which is where these claims live in the form the code is verified against.
Related reading
- RBAC & RLS — roles, the capability matrix, and hierarchy rules
- Seat billing — the BillingAdapter seam and the single enforcement gate
- Testing — the fake Supabase client and the two-layer proof
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll respond within 48 hours.