Reusing one multi-tenant base across every client without rebuilding it

How a multi-tenant SaaS boilerplate turns the org/membership/role model into something you build once and ship to every client — system roles, per-org permissions, and the invariants a fresh rebuild always forgets.

MC
Martin Coll

If you build SaaS for clients, you have already written the same tenancy layer three or four times. Every project needs organizations, members, roles, invitations, per-org API keys, billing scoped to the account — and every time you start from a blank repo, that scaffolding eats the first week before you write a single line of the thing the client is actually paying for. A multi-tenant SaaS boilerplate is the bet that this layer is boring enough, and identical enough across clients, that it should be built once and reused verbatim.

The reason it keeps getting rebuilt is that it looks trivial and isn't. As one agency dev put it on Indie Hackers (as of mid-2026): "workspace isolation, role-based permissions, billing per organization, custom domains, audit logs — each piece is a week of work." The list is real and the estimate is honest, because the hard part isn't the happy path — it's the invariants that only bite on client number three. This post walks through the exact org/membership/role model UseDeploy ships, so you can see what "reuse it, don't rebuild it" buys you concretely. It's all real code from the tenancy module.

The checklist you rewrite every time

When agencies are asked what they actually reuse between clients, the answer is consistent (as of 2026): login, signup, an admin panel, users and teams, roles, subscriptions. That list maps almost 1:1 to what a tenancy layer has to own:

  • Organizations — the tenant boundary. One row = one customer account.
  • Memberships — the many-to-many between a user and the orgs they belong to.
  • Roles — owner/admin/member out of the box, plus custom roles per org.
  • Invitations — email a token, expire it, assign a role on accept.
  • Per-org API keys — machine access scoped to one account's permissions.
  • The request boundary — resolve "which org is this call for" and prove the caller is allowed in it, on every request.

None of these are hard in isolation. The cost is that they interlock, and the interlocks are where a from-scratch rebuild leaks. So the reuse story isn't "copy the models" — it's "copy the models plus the invariants that hold them together."

One org is one tenant, and creation is one call

The Organization aggregate is the tenant. It carries a unique slug, an isActive flag, and a nullable deletedAt for soft deletion — so suspending or deleting a client account is a state change on one row, not a cascade you write by hand:

export interface OrganizationProps {
  id: OrganizationId;
  slug: string;
  name: string;
  isActive: boolean;
  deletedAt: Date | null;
  createdAt: Date;
  updatedAt: Date;
}

The part you'd otherwise rebuild every time is what happens when an org is created. A new tenant is useless without roles and an owner, so CreateOrganizationUseCase bootstraps all of it atomically — three system roles and an owner membership, in one call:

const SYSTEM_ROLE_DEFAULTS = {
  owner: {
    description: 'Full access. System role; cannot be edited or deleted.',
    permissions: ['*:*'] as const,
  },
  admin: {
    description: 'Manage users, settings, and reports. System role.',
    permissions: [
      'users:read', 'users:create', 'users:update', 'users:delete',
      'roles:read', 'settings:read', 'settings:update', 'reports:read',
      'invitations:create', 'invitations:read', 'invitations:delete',
    ] as const,
  },
  member: {
    description: 'Read-only access. System role.',
    permissions: ['users:read', 'reports:read'] as const,
  },
} as const;
const org = Organization.create({ id: orgId, slug: input.slug, name: input.name });
await this.orgs.save(org);

const systemRoles = SYSTEM_ROLE_NAMES.map((name) =>
  Role.createSystem({
    id: randomUUID() as RoleId,
    organizationId: orgId,
    name,
    description: SYSTEM_ROLE_DEFAULTS[name].description,
    permissions: [...SYSTEM_ROLE_DEFAULTS[name].permissions],
  }),
);
for (const role of systemRoles) await this.roles.save(role);
const ownerRole = systemRoles.find((r) => r.name === 'owner')!;

const membership = Membership.create({
  id: randomUUID() as MembershipId,
  organizationId: orgId,
  userId: input.creatorUserId as UserId,
  roleId: ownerRole.id,
});
await this.memberships.save(membership);

Every client you onboard gets the same owner/admin/member scaffolding for free, with the creator seated as owner. That's the shape of "reuse": not a snippet you paste, but a use case whose behavior is identical across projects.

The join table is where isolation lives. OrganizationMember has a compound unique on [organizationId, userId] — a user is a member of an org exactly once — and cascades on org deletion, so tearing down a client account doesn't orphan its memberships:

model OrganizationMember {
  id             String   @id @default(cuid())
  organizationId String
  userId         String
  roleId         String
  createdAt      DateTime @default(now())

  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  user         User         @relation(fields: [userId], references: [id], onDelete: Cascade)
  role         Role         @relation(fields: [roleId], references: [id], onDelete: Restrict)

  @@unique([organizationId, userId])
}

Note the asymmetry: user and organization cascade, but role is onDelete: Restrict. You can't delete a role that still has members — the database refuses it. That's one of those interlocks a rebuild forgets until a client deletes a role in production and their team's permissions evaporate.

Roles are data, not code

The reason the same tenancy layer fits every client is that roles are a runtime table, not a TypeScript enum. Permissions are a resource:action grid, plus a *:* super grant and one coarse extra:

export const RESOURCES = [
  'users', 'roles', 'settings', 'reports', 'organizations',
  'billing', 'invitations', 'webhooks', 'api-keys', 'queues',
] as const;
export const ACTIONS = ['create', 'read', 'update', 'delete', 'manage'] as const;

export type Permission = `${Resource}:${Action}` | ExtraPermission | '*:*';

export const hasPermission = (
  granted: ReadonlyArray<Permission>,
  required: Permission,
): boolean => granted.includes(SUPER_ADMIN) || granted.includes(required);

System roles (owner/admin/member) are immutable — the Role aggregate rejects renaming, re-describing, or re-permissioning them. But any org can create its own custom roles from the same permission catalog, scoped to that org by a @@unique([organizationId, name]) in the schema. So a legal-tech client's "Paralegal" role and a fintech client's "Auditor" role live in the same table, same code, zero forks. The full role model — system vs. custom, the permission catalog, how grants resolve — is on the RBAC feature page.

The request boundary you resolve once

Here's the piece that's genuinely a week of work to get right from scratch: turning an incoming request into a proven org context. UseDeploy does it in one middleware. It reads the x-organization-id header, confirms the caller actually holds a membership and role in that org, checks the org isn't suspended, and only then populates req.organization:

const membership = await container.cradle.membershipRepository.findByOrgAndUser(
  orgId, session.userId as UserId,
);
if (!membership) {
  sendError(res, new ForbiddenError('You are not a member of this organization'));
  return;
}
const role = await container.cradle.roleRepository.findByIdForOrg(membership.roleId, orgId);
if (!role) {
  sendError(res, new ForbiddenError('Membership role missing'));
  return;
}
// Suspension gate: looked up fresh per request so a suspend takes effect immediately.
const org = await container.cradle.organizationRepository.findById(orgId);
if (org && isOrgSuspended(org)) {
  sendError(res, { code: 'ORG_SUSPENDED', message: 'Organization suspended' }, 403);
  return;
}
req.organization = { id: orgId, roleId: role.id, roleName: role.name, permissions: role.permissions };

Downstream, guarding a route is one line. requirePermission reads the grants the middleware attached and 403s if they don't include the permission (or *:*):

export const requirePermission = (permission: Permission): RequestHandler =>
  (req, res, next) => {
    if (!req.session) return sendError(res, new UnauthorizedError('Authentication required'));
    const grants = req.grants ?? [];
    if (!hasPermission(grants, permission)) {
      return sendError(res, new ForbiddenError(`Missing permission: ${permission}`));
    }
    next();
  };

The header-to-context resolution and the permission gate are the same on every client project. You wire routes; you don't re-derive the boundary. Per-org API keys ride the same rails — an API-key session pins req.organization to the key's own scope, and the middleware deliberately skips the membership refetch so a scoped key never inherits its creator's full grants.

The invariants that bite on client number three

This is the real argument for reusing a base instead of rebuilding: the tenancy layer is a minefield of edge cases that don't show up in a demo. UseDeploy encodes them so you inherit them for free.

  • You can't remove or demote the last owner. Both RemoveMemberUseCase and ChangeMemberRoleUseCase refuse it: "Cannot remove the last owner; transfer ownership first." A fresh build almost always ships the version where an admin can lock everyone out of an account.
  • Ownership transfer is a single operation, not two independent role changes — the target is promoted to owner and the previous owner is demoted (to admin by default) together, so there's no window with zero owners or two.
  • Invitations expire and never persist the plaintext token. The default TTL is 7 days; only a hash is stored, and the accept link's plaintext token is returned once and emailed directly — it never rides the event bus (the outbox persists payloads, and a token must never be persisted).

None of these are exotic. They're exactly the corners a rebuild cuts under deadline, and exactly the corners that turn into a security incident with a client's name on it.

What it deliberately isn't

Honest scope matters more than a feature list. This is shared-schema multi-tenancy: one Postgres database, an organizationId column on every org-owned table, not a database or schema per tenant. If a client contractually needs physical isolation, that's a different architecture and this base isn't it.

Because isolation is a column, the risk is the classic one — a query that forgets its where: { organizationId }. UseDeploy's backstop for that is a Prisma query extension that throws instead of leaking; it's a separate layer, covered in depth in the tenant-isolation guard post. Worth reading before you trust the reuse story, because it also documents what the guard does not catch (raw SQL, for one).

The margin math

The pitch to an agency isn't "cheaper." It's the same invoice, delivered faster, without shipping the boring-and-dangerous parts fresh each time. You quote the client the same rate; the tenancy week is already spent. And you don't put your name on an account-takeover bug because someone forgot the last-owner check on a Friday.

If that's the model you run — one reusable base, many client accounts — the agencies pillar lays out the whole reuse-per-client workflow, and the tenancy feature page is the map of everything the org/membership/role layer ships. Build it once; charge for it every time.