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

1

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()
});
2

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];
}
3

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 });
4

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; }
}
5

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 design

Common 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.

Max 2000 characters

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

Related reading