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

Max 2000 characters

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

Related reading