Skip to content
Last updated Give Feedback

Multi-Tenancy

ecommus is built for multi-tenancy from day one. A single deployment can serve unlimited independent stores, each with their own products, orders, customers, and settings — completely isolated from each other.

Every HTTP request that touches tenant data must go through the resolveTenant() middleware, which sets req.tenantId before any route handler runs.

Resolution order:

  1. X-Tenant-Id header (for programmatic API access)
  2. Subdomain: mystore.ecommus.app → tenant slug mystore
  3. Custom domain lookup in settings table
apps/api/src/middlewares/auth.ts
export async function resolveTenant(req: FastifyRequest, reply: FastifyReply) {
const header = req.headers['x-tenant-id'] as string | undefined;
const subdomain = extractSubdomain(req.hostname);
const tenantId = header ?? await lookupTenantByDomain(subdomain);
if (!tenantId) {
return reply.code(400).send({ error: 'tenant_not_found' });
}
req.tenantId = tenantId;
}

Every database query in a route handler must include a tenantId filter. This is enforced by code review and the requireTenant preHandler.

// ✅ Correct — all data filtered by tenant
const products = await db.select()
.from(schema.products)
.where(and(
eq(schema.products.tenantId, req.tenantId), // ← required
isNull(schema.products.deletedAt)
));
// ❌ Forbidden — cross-tenant data leak (OWASP A01)
const products = await db.select().from(schema.products);

All admin routes use this preHandler chain:

{
preHandler: [requireAuth, resolveTenant, requireTenant]
}
  • requireAuth — verifies JWT, sets req.user
  • resolveTenant — resolves and sets req.tenantId
  • requireTenant — returns 403 if req.tenantId is not set

Every table that holds tenant-specific data has a tenantId column:

-- All tenant-scoped tables share this pattern
tenant_id TEXT NOT NULL,
-- plus a composite index for performance:
INDEX idx_products_tenant (tenant_id, deleted_at)

See packages/db/src/schema/ for all table definitions.

The storefront resolves the tenant from the domain name at request time:

mystore.ecommus.app → X-Tenant-Id: mystore → API calls filter by tenantId

In development, use X-Tenant-Id: demo header or configure TENANT_ID=demo in .env.

Plugins also receive tenantId via PluginContext. Any plugin that stores settings or data must scope it to the tenant:

// Plugin using settings API
const settings = await ctx.settings.get('my-plugin:api-key', req.tenantId);

Plugin settings in the settings table use the key pattern plugin:<name>:settings and are stored as a JSON blob per tenant.