Guide

Multi-tenancy in SvelteKit, at the application layer

Most multi-tenancy advice starts with database features you may not have (RLS, schema-per-tenant) or vendor products you may not want. For small B2B SaaS there's a third path: tenancy as plain application logic, enforced where your queries are built. This guide shows the patterns we ship in our multi-tenant SvelteKit starter — each one backed by tests you can read.

What "multi-tenant" means at the application layer

One shared schema, every tenant-scoped row carries an org_id, and the scope is enforced where queries are constructed — never trusted from the client. That's the whole idea. Row-Level Security moves the same discipline into the database engine; IdP org features move it into a vendor. App-layer tenancy keeps it in your codebase where your product logic already lives, which is also why it ports across databases when SQLite-on-a-laptop becomes Postgres-under-load.

The tradeoff is honesty about responsibility: no engine will catch a forgotten WHERE clause for you. You compensate with structure — services that own all writes, indexes that make duplication impossible, and tests that try to break scope boundaries the way an attacker would.

Modeling organizations, membership, and roles

Three tables carry most products surprisingly far:

organizations(id, name, slug, created_at_ms)
memberships(id, org_id → organizations, user_id → users,
             role ∈ 'owner'|'admin'|'member', created_at_ms)
invites(id, org_id, token_hash, role, invited_by_user_id,
        expires_at_ms, accepted_at_ms?, revoked_at_ms?)

Decisions that bite later, made deliberately here:

Invite flows that don't leak access

An invite link is a bearer credential with a delivery problem. Treat it like one:

UPDATE invites SET accepted_at_ms = ?
WHERE id = ? AND accepted_at_ms IS NULL
  AND revoked_at_ms IS NULL AND expires_at_ms > ?
-- zero rows updated = someone else got there first

Two people clicking simultaneously is a normal Tuesday, not a race condition to debug — exactly one wins, the other gets an honest "already used." Revocation rides the same predicate. And when the org is at its seat limit, acceptance fails closed at join-time with a human-readable error, because billing state changed between link creation and click.

Enforcing roles on every request

Hidden buttons are user experience. Authorization is what the server decides after ignoring everything the client sent. In practice: every page load re-reads the actor's membership from the database; every form action re-checks the specific permission (members.invite, billing.manage, …) against a capability matrix — three roles mapped to explicit capabilities, no inheritance surprises. Two rules do heavy lifting beyond the matrix:

Each mutation writes its audit row in the same service call — who did what to whom, in an append-only table with no update or delete codepath. Enterprise pilots ask for this in the first week; retrofitting it onto untested mutations is how weeks disappear.

Checklist: shipping your own tenancy layer

Or skip the line: the starter kit implements everything above with 52 tests over the auth, invite, RBAC, seat, and audit paths.

Join the waitlist

No spam, no drip sequence. One email when it’s real.

Stored in our own database — no third party. Deleted on request.