CrabStack / NestJS multi-tenant boilerplate

NestJS multi-tenant boilerplate: workspaces in one shared database

CrabStack's NestJS 12 backend is multi-tenant through workspaces. Every user has a personal workspace, can create shared ones, and invites people in with a role. All workspaces live in one Postgres database and one deployment, separated by a workspace_id on every workspace-owned row and in every query that reads one.

This page explains that model precisely, because "multi-tenant" covers several designs and this template implements one of them.

Which kind of multi-tenancy

There are three common designs: a database per tenant, a schema per tenant, or one shared schema with a tenant id on each row. CrabStack uses the shared schema. One migration set, one connection pool, one place to back up. The isolation is enforced in the application, by route guards and query filters.

It does not ship a database, schema or domain per tenant, or per-tenant configuration.

The data model

Migration 018_workspaces.ts adds three tables:

  • workspaces, with a name and a personal flag.
  • workspace_members, one row per user per workspace, with a role of owner, admin or member. A partial unique index allows one owner per workspace, so the database enforces it.
  • workspace_invites, pending invites.

A resource table carries workspace_id, with ON DELETE CASCADE, and a created_by for its author. The sample items table is the worked example.

The personal workspace is created with the user, in the same transaction. Every user always has a workspace, so the frontend has no "no workspace" state to handle.

How a request is checked

Workspace routes put the workspace in the path: /workspaces/:workspaceId/items. A guard looks up the caller's membership.

  • Not a member, or no such workspace: 404. The two answers are the same, so workspace ids cannot be probed.
  • A member below the route's role: 403.
  • Otherwise the handler receives the workspace and the caller's role.

The guard proves the caller belongs to the workspace in the path. It does not prove the item id belongs to it. So every service query filters on the workspace in the same statement: where({ id, workspace_id }). An item id from another workspace matches nothing and returns 404.

The isolation test

workspace-isolation.http.spec.ts boots the application against a real Postgres. As a member of workspace A, it requests workspace B's items every way the routes allow: by B's id in the path, and by B's item id under A's path. Every answer must be 404, and B's item must be unchanged afterwards. The workspace and API key specs hold 55 test cases in total.

That spec covers the sample items routes. Nothing runs it against a module you add. The new-module skill tells the agent to add each new module's routes to it, so check that the diff does.

Roles, invites and API keys

  • Members read and write the workspace's data. Admins also rename the workspace, invite people and manage members. Only the owner transfers ownership or deletes the workspace.
  • Invites carry a random 32-byte token stored as a SHA-256 hash. They expire after 7 days, work once, and need the signed-in user's email to match. Send limits apply per sender and per address, and a workspace can hold at most 50 pending invites.
  • API keys let a script call a workspace's item routes with member rights, in that workspace only.
  • Writes whose correctness depends on the caller's role lock the workspace row and check the role again inside the transaction.

Billing per workspace

A workspace's plan is its owner's plan. Billing stays keyed to the user. The free-plan item cap counts every workspace the owner owns, so creating more workspaces does not lift it.

Adding your own tenant-scoped resource

The new-module skill scaffolds workspace-scoped modules by default: a workspace_id foreign key, a controller under workspaces/:workspaceId/... with the member guard, services that filter on the workspace, a route policy entry, and a spec that one workspace gets 404 for another's rows.

Who it fits

  • B2B apps where a customer is a team: agencies, internal tools, small SaaS products.
  • You want a tested pattern for tenant isolation, with no extra databases to run.

Who it does not fit

  • Contracts or regulations require a separate database per customer.
  • You need a domain or subdomain per tenant.
  • You need per-seat billing or a plan per workspace that differs from the owner's.

Related

  • NestJS SaaS boilerplate: everything that ships
  • NestJS Postgres boilerplate: the queries and migrations behind workspaces
  • NestJS Stripe boilerplate: the billing that sets each owner's plan
  • Vibe coding SaaS template: which checks fail the build and which depend on the agent
  • Docs: Teams and Workspaces, Adding a Module, REST surface, Testing
  • Compare: vs ShipFast, vs Makerkit, vs supastarter

FAQ

Is CrabStack a multi-tenant NestJS boilerplate? Yes, with a shared database and shared schema. Tenants are workspaces, and every workspace-owned row and every query that reads one carries the workspace id. It does not create a database, schema or domain per tenant.

How is one tenant's data kept from another? A guard checks membership of the workspace in the URL, and every query filters on that workspace. A test requests another workspace's data every way the routes allow and expects 404.

Does it use Postgres row-level security? No. Isolation is enforced in the application, by guards and query filters, and tested over HTTP.

Can a user belong to several workspaces? Yes. Every user has a personal workspace and can join or create shared ones. A switcher in the header picks the active one.

How does billing work with teams? Each workspace uses its owner's plan. Billing records stay attached to users.

Get the repo

$199 once, before tax: the repo, the tests, lifetime updates. Stripe is the merchant of record: checkout adds VAT or sales tax where it applies, and a local-currency price carries Stripe's 2-4% conversion fee. The full docs are public at /docs.