Multi-tenant (v1)
One Next.js deployment can serve multiple WordPress backends by domain.
Off by default — with MULTI_TENANT_ENABLED unset, behavior is
byte-for-byte identical to a normal single-tenant deployment.
This is a narrowly-scoped first version. See "Deferred" below for what's intentionally not included yet.
Enabling it
Set
MULTI_TENANT_ENABLED=1in the Next.js environment.Edit
nextjs-starter-kit/tenants.config.json(committed with an empty{ "tenants": [] }default so the app works out of the box). Seetenants.config.example.jsonfor the full schema:{ "tenants": [ { "id": "acme", "hosts": ["acme.example.com"], "wordpressApiUrl": "https://cms.acme.example.com/graphql", "wordpressUrl": "https://cms.acme.example.com", "siteUrl": "https://acme.example.com", "revalidationSecret": "...", "previewSecret": "...", "authToken": "" } ] }Point each tenant's DNS/domain at this same Next.js deployment.
Because tenants.config.json is committed (not gitignored — see "Why the
registry is committed, not gitignored" below), the registry — including any
secrets you put in it — ends up in your repository's git history. For a
private deployment repo that's usually fine; if you're working in a public
fork, keep real secrets out of it (e.g. leave revalidationSecret/
previewSecret blank here and layer your own env-var-based override on top)
rather than committing them.
How it works
middleware.tsreads the request'sHostheader, resolves it to a tenant ID viaresolveTenantIdFromHost()(src/lib/tenant.ts), and sets it as anx-headlesscore-tenantrequest header. No match, or the flag off → request passes through untouched.getTenantConfig()(same file) reads that header and returns the matching tenant's config, or the existing single-tenantconfig.tsvalues as a fallback. This fallback is what makes the feature safe to ship dark.fetchAPI()(src/lib/graphql.ts) resolves tenant config once per request and uses it for the WordPress endpoint, auth token, and cache-tag prefixing (${tenant.id}:${tag}) — the single choke point all ~25 data fetchers go through./api/revalidateand/api/previewvalidate the incoming secret against that tenant's own secret, and rate-limit buckets are tenant-keyed (revalidate:${tenantId}:${ip}) so one tenant's traffic can't exhaust/pollute another's budget on a shared instance.- License validity is tenant-independent "for free": each tenant's own
WordPress backend computes its own
themeSettings.license— no multi-tenant-specific license code was needed oncefetchAPIresolved the right backend per tenant.
Deferred (not in v1)
- Distributed rate limiting. The rate limiter is still an in-memory,
per-process
Map(pre-existing limitation, documented insrc/lib/rateLimit.ts) — only the tenant+IP keying was fixed here, not the underlying architecture. - DB-backed tenant registry.
tenants.config.jsonis bundled at build time; adding/removing a tenant requires a redeploy. - Per-tenant admin UI. Editing the registry is a manual JSON edit.
- Automatic per-tenant TLS provisioning. Infra-level concern, out of this codebase's scope — point your reverse proxy/host at the domains and handle certs there.
Known risk: revalidatePath() is not tenant-scoped
/api/revalidate's revalidateTag() calls are tenant-prefixed and safe.
Its revalidatePath() calls are not — Next.js's path-based cache
invalidation isn't segmented by Host, so if two tenants happen to share a
relative path (e.g. both have a /blog page), one tenant's webhook firing
revalidatePath("/blog") could invalidate the other tenant's /blog page
too. Prefer tag-based revalidation (tag in the webhook payload) over
path in multi-tenant deployments until this is addressed — full isolation
would need either separate deployments per tenant domain or a custom cache
handler, both out of scope for v1.
Why the registry is committed, not gitignored
An earlier draft of this feature planned to gitignore
tenants.config.json and ship only an example file. That breaks on a fresh
clone: tenant.ts statically imports the registry (required for it to
work in the Edge middleware runtime, which can't read files at request
time), and a missing file fails the build. Shipping a committed, empty
default ({ "tenants": [] }) keeps the build always green — the feature is
inert until both the env flag and a populated registry are present.