Build a multi-tenant app
Give every customer a tenant, issue API keys, gate features by plan and invite teammates, with isolation enforced for you.
Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
You will turn on tenancy, let people create organisations and invite teammates, issue API keys for customer code, and gate a feature by plan. It takes about 30 minutes. The calls run on the in-memory fakes in tests, with the tenant directory held in memory.
You need
- A MainSDK app with
authanddb(see Thirty-minute path). - A
paysubscription, if you gate by plan.
1. Turn on tenancy
tenancy:
isolation: shared
resolve: [apikey, session, subdomain]
personal: true
roles: [owner, admin, member, viewer]
system: { allow: [src/admin/**, scripts/**] }Each person gets a personal tenant at first sign-in. Every request now has a tenant, and every db query is scoped to it. Never pass a tenant id by hand. Organisations and invites also need auth configured (a key and a method, see Sign-in and sessions).
2. Create an organisation and invite a teammate
import { auth } from "mainsdk";
const org = await auth.orgs.create({ name: "Acme", region: "eu" }); // the creator becomes owner
await auth.invites.create({ email: "jane@acme.com", roles: ["member"] });auth.invites.create() acts on the current tenant, so the caller must be an owner or admin of an organisation (a personal tenant refuses invites). The invite is a getqr.id link, bound to Jane's email address, single use, and expires in 7 days.
3. Read the tenant in your code
import { auth, models, s, tenant } from "mainsdk";
const { orders } = models({
orders: s.object({ item: s.string(), total: s.int() }),
}); // declared once in lib/orders.ts, shown inline so the example stands alone
export async function GET(req: Request) {
const { user } = await auth.require(req);
const t = tenant.current(); // { id, kind, region, plan, roles, ... }
const page = await orders.for(user).list({ limit: 20 }); // only this tenant's rows
return Response.json({ tenant: t.id, items: page.items });
}In a job, wrap the work: tenant.run(id, fn). Use tenant.system(reason, fn) only in the admin paths you listed under tenancy.system.allow.
4. Issue an API key
Creating a key needs a role that holds apikeys.manage, and the scopes you grant must be declared under apikeys.scopes.
access:
roles:
owner: [apikeys.manage, orders.read]
apikeys:
scopes: [orders.read]import { apikeys } from "mainsdk";
const { secret } = await apikeys.create({ name: "CI", scopes: ["orders.read"] }); // shown onceAdd apikey first under tenancy.resolve, and a request carrying the key gets that tenant. Never hand-roll key storage.
5. Gate a feature by plan
plans:
free: { features: [basic], limits: { projects: 5 } }
pro: { features: [basic, export], limits: { projects: 50 }, prices: [price_pro_monthly] }
entitlements: { default: free }import { entitlements } from "mainsdk";
entitlements.require("export"); // throws MAIN-PLAN-0001 (403) with an upgrade hint on the free planCheck it worked
npx mainsdk check passes. A request with tenant A's key cannot read tenant B's orders: it gets a 404.
If it fails
| Code | Fix |
|---|---|
MAIN-TENANT-0001 | A job ran with no tenant. Wrap it in tenant.run(id, fn) |
MAIN-TENANT-0004 | tenant.system() ran in a file outside tenancy.system.allow. Move it, or list the path (mainsdk check reports the same as MAIN-TENANT-0011) |
MAIN-PLAN-0001 | The tenant's plan lacks the feature. Move it to a plan that has it |
MAIN-PLAN-0010 | A feature no plan declares. Add it under plans |
Next
Was this page helpful?