MainSDK
Search⌘K
How-to

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.

Building for 0.3

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.
Stability levels
Updated 2026-10-28

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 auth and db (see Thirty-minute path).
  • A pay subscription, if you gate by plan.

1. Turn on tenancy

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

app/api/orgs/route.ts
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

app/api/orders/route.ts
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.

main.yaml
access:
  roles:
    owner: [apikeys.manage, orders.read]
apikeys:
  scopes: [orders.read]
app/api/keys/route.ts
import { apikeys } from "mainsdk";

const { secret } = await apikeys.create({ name: "CI", scopes: ["orders.read"] });   // shown once

Add 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

main.yaml
plans:
  free: { features: [basic], limits: { projects: 5 } }
  pro:  { features: [basic, export], limits: { projects: 50 }, prices: [price_pro_monthly] }
entitlements: { default: free }
app/api/export/route.ts
import { entitlements } from "mainsdk";

entitlements.require("export");   // throws MAIN-PLAN-0001 (403) with an upgrade hint on the free plan

Check 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

CodeFix
MAIN-TENANT-0001A job ran with no tenant. Wrap it in tenant.run(id, fn)
MAIN-TENANT-0004tenant.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-0001The tenant's plan lacks the feature. Move it to a plan that has it
MAIN-PLAN-0010A feature no plan declares. Add it under plans

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet