N
HeadlessCoreHeadless Core
Back to Home

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

  1. Set MULTI_TENANT_ENABLED=1 in the Next.js environment.

  2. Edit nextjs-starter-kit/tenants.config.json (committed with an empty { "tenants": [] } default so the app works out of the box). See tenants.config.example.json for 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": ""
        }
      ]
    }
    
  3. 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.ts reads the request's Host header, resolves it to a tenant ID via resolveTenantIdFromHost() (src/lib/tenant.ts), and sets it as an x-headlesscore-tenant request 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-tenant config.ts values 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/revalidate and /api/preview validate 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 once fetchAPI resolved 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 in src/lib/rateLimit.ts) — only the tenant+IP keying was fixed here, not the underlying architecture.
  • DB-backed tenant registry. tenants.config.json is 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.