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 77 tests, for $79–$129.
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
- 77 tests covering the hard parts
- TypeScript, SvelteKit, Drizzle ORM, SQLite
Get in touch
Questions about the product, team licenses, or anything else? We'll 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