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.