Guide
Multi-Tenant SvelteKit Template: The Complete Guide
Building a B2B SaaS with SvelteKit? You need multi-tenancy — organizations, memberships, role-based access control, seat billing, and audit logging.
Here's how to implement each piece, and why a production-ready starter saves you weeks.
The 5 components of multi-tenancy
Define your data model
Organizations, memberships, invites, audit log. Each org is an isolated tenant.
// Schema: organizations + memberships
const organizations = sqliteTable('organizations', {
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
createdAtMs: integer('created_at_ms').notNull()
});
const memberships = sqliteTable('memberships', {
id: text('id').primaryKey(),
orgId: text('org_id').references(() => organizations.id),
userId: text('user_id').references(() => users.id),
role: text('role').notNull(), // 'owner' | 'admin' | 'member'
createdAtMs: integer('created_at_ms').notNull()
});Implement RBAC
Role hierarchy with capability matrix. Server-side enforcement on every action.
// RBAC: owner > admin > member
const MATRIX: Record<Role, readonly Permission[]> = {
owner: PERMISSIONS, // all 9: org.view, members.view, members.invite,
// members.remove, members.role.set, invites.revoke,
// audit.view, billing.manage, ownership.transfer
admin: ['org.view', 'members.view', 'members.invite', 'members.remove',
'members.role.set', 'invites.revoke', 'audit.view'],
member: ['org.view', 'members.view']
};
const RANK: Record<Role, number> = { member: 0, admin: 1, owner: 2 };
// Act/grant downward only — strictly below your own rank.
function mayActOn(actor: Role, target: Role): boolean {
return RANK[actor] > RANK[target];
}
function mayGrant(actor: Role, granted: Role): boolean {
return RANK[actor] > RANK[granted];
}Add invite flows
Single-use hashed tokens, 7-day expiry, atomic claim. No double-acceptance.
// Invite: single-use, hashed, atomic
const token = randomBytes(32).toString('base64url');
const tokenHash = sha256(token);
// Atomic claim — WHERE clause is the concurrency gate
const claimed = await db.update(invites)
.set({ acceptedAtMs: Date.now(), acceptedByUserId: userId })
.where(and(
eq(invites.id, invite.id),
isNull(invites.acceptedAtMs),
isNull(invites.revokedAtMs)
))
.returning({ id: invites.id });Wire seat billing
Pluggable BillingAdapter. Mock for dev, real MoR for production.
// BillingAdapter: pluggable seam (src/lib/server/billing/adapter.ts)
type SubscriptionStatus = 'none' | 'active' | 'trialing' | 'past_due' | 'canceled';
interface SubscriptionState {
status: SubscriptionStatus;
planSeats: number;
renewsAtMs?: number | null;
}
interface BillingAdapter {
readonly name: string;
getSubscriptionState(orgId: string): Promise<SubscriptionState>;
createCheckoutUrl(input: { orgId: string; seats: number }): Promise<string | null>;
}
// Mock for development — always 'active', 3 seats, no checkout URL yet
class MockBillingAdapter implements BillingAdapter {
readonly name = 'mock';
private readonly planSeats = Number(process.env.MOCK_PLAN_SEATS ?? 3);
async getSubscriptionState(_orgId: string): Promise<SubscriptionState> {
return { status: 'active', planSeats: this.planSeats, renewsAtMs: null };
}
async createCheckoutUrl(): Promise<string | null> { return null; }
}Add audit logging
Append-only by construction. No UPDATE or DELETE path exists.
// Audit: append-only, no mutations possible
const auditLog = sqliteTable('audit_log', {
seq: integer('seq').primaryKey({ autoIncrement: true }),
orgId: text('org_id'),
actorUserId: text('actor_user_id'),
action: text('action').notNull(),
metadataJson: text('metadata_json'),
createdAtMs: integer('created_at_ms').notNull()
});
// No UPDATE or DELETE policy — append-only by designCommon pitfalls
- Trusting client-submitted roles:Never read the role from the client. Always re-derive it from the database on every request.
- Read-then-write for invites:Two people clicking the same invite link simultaneously can both get in. Use atomic UPDATE with WHERE clause.
- Mutable audit logs:If you can UPDATE or DELETE audit entries, it's not an audit log. Make it append-only by design.
- Skipping the last-owner check:If the last owner leaves, the org becomes orphaned. Prevent this in code.
- UI-only access control:Hiding buttons isn't security. Every load/action must re-check permissions against fresh DB state.
Build vs. buy
You can build all of this yourself. It'll take 4–7 weeks for a senior engineer. That's $10,000–$35,000 in opportunity cost.
Or you can use a production-ready starter that includes all of this, tested with 357 tests, for $79.
The starter handles the hard parts — multi-tenancy, RBAC, billing, audit logging — so you can focus on your actual product.
What's included in the starter
- Organizations with slug-based URLs
- Member invitations with single-use hashed tokens
- Role hierarchy (owner > admin > member) with capability matrix
- Server-side RBAC enforcement on every action
- Seat-based billing with pluggable adapter
- Append-only audit log
- Rate limiting on authentication endpoints
- 357 tests covering the hard parts
- TypeScript, SvelteKit, Drizzle ORM, SQLite
SQLite is the only difference. The same organizations, invites, RBAC, seat billing, and audit log ship onPostgresand onSupabase— SQLite vs Postgres for a multi-tenant SaaSis the honest comparison of when each one is the right call.
Get in touch
Questions about the product, team licenses, or anything else? We'll aim to respond within 48 hours.
Related reading
- Multi-Tenant SvelteKit Starter — skip the build, start with a tested foundation
- Multi-Tenant Authentication — deep dive on auth architecture
- RBAC & Role Hierarchy — permission matrix and enforcement
- Starter Checklist — what to look for in a starter
- All documentation — guides, deep dives, and evaluations