Multitenant

Configuration

`tenants.config.json` schema, flags, and merge rules.

Single source of truth for markets, tenants, domain → tenant maps per environment, optional flags, experiments, and merged config blobs.

Location

  • Default: tenants.config.json at the repo / app root.
  • @multitenant/config: loadTenantsConfig({ cwd }) (default process.cwd()).

Typical layout (names vary by stack):

tenants.config.json

Invalid files throw InvalidTenantsConfigError — same checks as npx @multitenant/cli check. See Errors.

Minimal skeleton

{
  "version": 1,
  "defaultEnvironment": "local",
  "markets": {
    "us": {
      "currency": "USD",
      "locale": "en-US",
      "timezone": "America/New_York"
    }
  },
  "tenants": {
    "acme": {
      "market": "us",
      "domains": {
        "local": { "acme.localhost": "acme" },
        "production": { "acme.example.com": "acme" }
      }
    }
  }
}

Full example

A comprehensive example using all available fields:

{
  "version": 1,
  "defaultEnvironment": "local",
  "defaults": {
    "environment": "local",
    "localDomainTemplate": "{tenant}.localhost"
  },
  "markets": {
    "us": {
      "label": "United States",
      "currency": "USD",
      "locale": "en-US",
      "locales": ["en-US", "es-US"],
      "timezone": "America/New_York",
      "primaryDomain": "us.example.com",
      "fallbackTenant": "us-main",
      "config": {
        "support": { "tier": "standard", "phone": "+1-800-555-0100" }
      },
      "seo": {
        "defaultTitleTemplate": "%s | Example US",
        "defaultMetaDescription": "Example Inc. — US market",
        "canonicalBaseUrl": "https://us.example.com"
      },
      "theme": {
        "preset": "light",
        "tokens": { "primaryColor": "#0066cc", "borderRadius": 8 }
      }
    },
    "eu": {
      "label": "Europe",
      "currency": "EUR",
      "locale": "en-GB",
      "locales": ["en-GB", "de-DE", "fr-FR"],
      "timezone": "Europe/London",
      "primaryDomain": "eu.example.com",
      "config": {
        "support": { "tier": "standard" }
      }
    }
  },
  "tenants": {
    "us-main": {
      "label": "US Main",
      "market": "us",
      "domains": {
        "local": {
          "us.localhost": "us-main",
          "*.us.localhost": "us-main"
        },
        "development": {
          "us.dev.example.com": "us-main"
        },
        "staging": {
          "us.staging.example.com": "us-main"
        },
        "production": {
          "us.example.com": "us-main",
          "www.us.example.com": { "tenant": "us-main", "basePath": "/" }
        }
      },
      "paths": {
        "basePath": "/us"
      },
      "flags": {
        "newCheckout": true,
        "darkMode": false
      },
      "config": {
        "support": { "email": "help@us.example.com" }
      },
      "configByEnvironment": {
        "production": {
          "support": { "tier": "priority", "email": "vip@us.example.com" }
        }
      },
      "theme": {
        "preset": "brand-us"
      },
      "seo": {
        "defaultTitleTemplate": "%s | US Store",
        "canonicalBaseUrl": "https://us.example.com",
        "overrides": {
          "/sale": "Big Sale — US Store"
        }
      },
      "access": {
        "defaultRoles": ["viewer"],
        "permissions": ["read", "write"]
      },
      "database": {
        "envVar": "DATABASE_URL_US_MAIN"
      },
      "experiments": {
        "checkout-v2": {
          "forcedVariant": "new",
          "enabled": true
        }
      }
    },
    "eu-main": {
      "label": "EU Main",
      "market": "eu",
      "domains": {
        "local": {
          "eu.localhost": "eu-main"
        },
        "production": {
          "eu.example.com": "eu-main"
        }
      },
      "flags": {
        "newCheckout": false
      }
    }
  },
  "experiments": {
    "checkout-v2": {
      "description": "Test the new checkout flow",
      "defaultVariant": "control",
      "variants": ["control", "new"]
    }
  }
}

Real-world example: EU tenant with country-level markets

A common pattern for European businesses is one tenant per country with each country as its own market — separate currency, locale, timezone, and domain. This avoids a single catch-all "eu" market with mixed currencies and locales.

{
  "version": 1,
  "defaultEnvironment": "local",
  "markets": {
    "de": {
      "label": "Germany",
      "currency": "EUR",
      "locale": "de-DE",
      "locales": ["de-DE", "en-DE"],
      "timezone": "Europe/Berlin",
      "primaryDomain": "de.example.com",
      "config": {
        "support": { "phone": "+49-800-000-0000" },
        "legal": { "vatPrefix": "DE", "gdprBanner": true }
      }
    },
    "es": {
      "label": "Spain",
      "currency": "EUR",
      "locale": "es-ES",
      "locales": ["es-ES", "ca-ES", "en-ES"],
      "timezone": "Europe/Madrid",
      "primaryDomain": "es.example.com",
      "config": {
        "support": { "phone": "+34-900-000-000" },
        "legal": { "vatPrefix": "ES", "gdprBanner": true }
      }
    },
    "gb": {
      "label": "United Kingdom",
      "currency": "GBP",
      "locale": "en-GB",
      "timezone": "Europe/London",
      "primaryDomain": "uk.example.com",
      "config": {
        "support": { "phone": "+44-800-000-0000" },
        "legal": { "vatPrefix": "GB", "gdprBanner": true }
      }
    },
    "fr": {
      "label": "France",
      "currency": "EUR",
      "locale": "fr-FR",
      "locales": ["fr-FR", "en-FR"],
      "timezone": "Europe/Paris",
      "primaryDomain": "fr.example.com",
      "config": {
        "support": { "phone": "+33-800-000-000" },
        "legal": { "vatPrefix": "FR", "gdprBanner": true }
      }
    }
  },
  "tenants": {
    "eu-de": {
      "label": "EU — Germany",
      "market": "de",
      "domains": {
        "local": { "de.localhost": "eu-de" },
        "staging": { "de.staging.example.com": "eu-de" },
        "production": { "de.example.com": "eu-de" }
      },
      "flags": { "newCheckout": true },
      "database": { "envVar": "DATABASE_URL_EU_DE" }
    },
    "eu-es": {
      "label": "EU — Spain",
      "market": "es",
      "domains": {
        "local": { "es.localhost": "eu-es" },
        "staging": { "es.staging.example.com": "eu-es" },
        "production": { "es.example.com": "eu-es" }
      },
      "flags": { "newCheckout": false },
      "database": { "envVar": "DATABASE_URL_EU_ES" }
    },
    "eu-gb": {
      "label": "EU — United Kingdom",
      "market": "gb",
      "domains": {
        "local": { "uk.localhost": "eu-gb" },
        "staging": { "uk.staging.example.com": "eu-gb" },
        "production": { "uk.example.com": "eu-gb" }
      },
      "flags": { "newCheckout": true },
      "database": { "envVar": "DATABASE_URL_EU_GB" }
    },
    "eu-fr": {
      "label": "EU — France",
      "market": "fr",
      "domains": {
        "local": { "fr.localhost": "eu-fr" },
        "staging": { "fr.staging.example.com": "eu-fr" },
        "production": { "fr.example.com": "eu-fr" }
      },
      "flags": { "newCheckout": false },
      "database": { "envVar": "DATABASE_URL_EU_FR" }
    }
  }
}

Key points:

  • Each country is a market (de, es, gb, fr) — this keeps currency (EUR vs GBP), timezone, and locale cleanly separated.
  • Each tenant (eu-de, eu-es, …) belongs to exactly one market and maps country-specific domains across environments.
  • Shared config like legal.gdprBanner lives in the market config; tenant-specific overrides (e.g. different configByEnvironment for production) layer on top via Config merge.
  • Feature flags can differ per tenant — Germany and the UK opt into newCheckout while Spain and France do not.
  • Database URLs are per-tenant via envVar — the actual connection strings live in environment variables, never in the JSON file.

Top-level fields

FieldTypeRequiredDescription
version1YesConfig schema version. Currently only 1 is supported.
defaultEnvironment"local" | "development" | "staging" | "production"NoFallback environment used when the runtime does not explicitly pass one. If omitted, adapters typically default based on NODE_ENV.
marketsRecord<string, MarketDefinition>YesA map of market ids to their definitions. Markets define locale, currency, and timezone for a geographic region.
tenantsRecord<string, TenantDefinition>YesA map of tenant ids to their definitions. Each tenant belongs to a market and declares per-environment domain mappings.
experimentsRecord<string, ExperimentDefinition>NoTop-level A/B experiment definitions. Tenant-level experiments overrides must reference keys declared here.
defaultsTenantsDefaultsNoGlobal defaults applied across the config (e.g. default environment, local domain template).

Markets

Each key in the markets object is a market id (e.g. "us", "eu"). A market groups tenants that share the same geographic / locale settings.

Market fields

FieldTypeRequiredDescription
currencystringYesISO 4217 currency code for the market (e.g. "USD", "EUR", "GBP").
localestringYesDefault BCP 47 locale tag (e.g. "en-US", "de-DE"). When locales is set, this value must appear in the locales array.
timezonestringYesIANA timezone identifier (e.g. "America/New_York", "Europe/London"). Used for date/time formatting.
labelstringNoHuman-readable display name for the market (e.g. "United States"). Useful for admin UIs and debugging.
localesstring[]NoAll BCP 47 locale tags supported in this market (e.g. ["en-US", "es-US"]). When set, must include locale and must not contain duplicates. Omit to support only the default locale.
primaryDomainstringNoThe canonical production domain for this market (e.g. "us.example.com"). Informational — not used for resolution.
fallbackTenantstringNoTenant key to fall back to when resolution fails within this market.
configRecord<string, unknown>NoArbitrary key/value defaults. Merged into every tenant in this market as the first layer — before tenant config and configByEnvironment. See Config merge.
seoMarketSeoConfigNoDefault SEO settings for all tenants in this market. See SEO fields below.
themeThemeConfigRefNoDefault theme settings for all tenants in this market. See Theme fields below.

Market SEO fields

The seo object on a market:

FieldTypeDescription
defaultTitleTemplatestringPage title template (e.g. "%s | Example US"). %s is replaced with the page title.
defaultMetaDescriptionstringFallback <meta name="description"> content.
canonicalBaseUrlstringBase URL for canonical link tags (must be a valid URL, e.g. "https://us.example.com").

Theme fields

The theme object (used on both markets and tenants):

FieldTypeDescription
presetstringName of a theme preset (e.g. "light", "brand-us"). Interpretation is app-specific.
tokensRecord<string, string | number | boolean>Design tokens (e.g. { "primaryColor": "#0066cc", "borderRadius": 8 }). Keys and semantics are app-defined.

Tenants

Each key in the tenants object is a tenant id (e.g. "us-main", "acme"). A tenant represents an individual site or brand served by the multitenant app.

Tenant fields

FieldTypeRequiredDescription
marketstringYesThe market this tenant belongs to. Must match a key in markets.
domainsPartial<Record<EnvironmentName, DomainMap>>YesPer-environment domain-to-tenant mappings. See Domain mapping below.
labelstringNoHuman-readable display name (e.g. "US Main").
paths{ basePath?: string }NoOptional base path prefix. When set, the tenant is served under this subpath (e.g. "/us"example.com/us/…).
flagsRecord<string, boolean>NoBoolean feature toggles (e.g. { "newCheckout": true, "darkMode": false }). See Feature flags.
configRecord<string, unknown>NoArbitrary tenant-level config. Merged after market config and before configByEnvironment. See Config merge.
configByEnvironmentPartial<Record<EnvironmentName, Record<string, unknown>>>NoPer-environment config overlays. Each key (local, development, staging, production) holds a config object that is merged last — after market config and tenant config. See Config merge.
themeThemeConfigRefNoTenant-level theme overrides. Same shape as the market Theme fields.
seoTenantSeoConfigNoTenant-level SEO settings. Extends the market SEO shape with an optional overrides map. See Tenant SEO fields.
accessTenantAccessConfigNoAccess control defaults. See Access fields.
databaseTenantDatabaseConfigNoPer-tenant database connection. See Database fields.
experimentsRecord<string, { forcedVariant?: string; enabled?: boolean }>NoPer-tenant experiment overrides. Every key must reference a top-level experiments entry. See Experiments.

Domain mapping

The domains object maps each environment to a DomainMap — a record of host patterns to domain targets.

Environment names: "local", "development", "staging", "production".

Host patterns:

  • Exact host"us.example.com" matches only that hostname.
  • Wildcard"*.us.example.com" matches any subdomain (e.g. shop.us.example.com).

Domain target can be one of:

  • A string — the tenant key (e.g. "us-main").
  • An object{ "tenant": "us-main", "basePath": "/us" } for tenants served under a subpath.
"domains": {
  "local": {
    "us.localhost": "us-main",
    "*.us.localhost": "us-main"
  },
  "production": {
    "us.example.com": "us-main",
    "www.us.example.com": { "tenant": "us-main", "basePath": "/" }
  }
}

Tenant SEO fields

The tenant seo object extends the market SEO shape:

FieldTypeDescription
defaultTitleTemplatestringPage title template (overrides market value).
defaultMetaDescriptionstringFallback meta description (overrides market value).
canonicalBaseUrlstringCanonical base URL (overrides market value).
overridesRecord<string, string>Path-specific title overrides (e.g. { "/sale": "Big Sale — US Store" }).

Access fields

The access object controls authorization defaults:

FieldTypeDescription
defaultRolesstring[]Roles assigned by default to users in this tenant (e.g. ["viewer"]).
permissionsstring[]Permissions available in this tenant (e.g. ["read", "write"]).

Database fields

The database object configures per-tenant database connectivity:

FieldTypeDescription
envVarstringThe name of an environment variable that holds the database connection URL at runtime (e.g. "DATABASE_URL_US_MAIN"). Must be a valid env var name (letters, digits, underscores).

Security: Never put raw connection URLs or credentials in tenants.config.json — it is checked into source control. Instead, store database URLs in environment variables and reference them by name via envVar. At runtime, use resolveTenantDatabaseUrl from @multitenant/database to read the actual connection string from the environment.

Feature flags (flags)

Server: isTenantFeatureEnabled(registry, tenantKey, name) in @multitenant/core. Client: useTenantFlag(name) when you have a resolved tenant in context.

There is no separate features field — if a CMS says "features", map that object into flags before validateTenantsConfig.

"flags": {
  "newCheckout": true,
  "darkMode": false
}

Using 3rd-party feature flag providers

Static flags in tenants.config.json work well for simple per-tenant toggles, but production apps often rely on dynamic feature flag services like LaunchDarkly or Split.io.

The recommended pattern is to sync the external provider's evaluation into multitenant's flags at boot time (or periodically), so the rest of your app can use the unified isTenantFeatureEnabled / useTenantFlag API.

LaunchDarkly example
import * as LaunchDarkly from '@launchdarkly/node-server-sdk';
import {
  loadTenantsConfig,
  validateTenantsConfig,
} from '@multitenant/config';

const ldClient = LaunchDarkly.init(process.env.LAUNCHDARKLY_SDK_KEY!);
await ldClient.waitForInitialization({ timeout: 5 }); // seconds

// Load the base config from tenants.config.json
const baseConfig = await loadTenantsConfig();

// For each tenant, evaluate LaunchDarkly flags and merge them in
for (const [tenantKey, tenant] of Object.entries(baseConfig.tenants)) {
  const ldContext = { kind: 'tenant', key: tenantKey };
  const newCheckout = await ldClient.variation('new-checkout', ldContext, false);
  const darkMode    = await ldClient.variation('dark-mode', ldContext, false);

  tenant.flags = {
    ...tenant.flags,      // keep any static flags from JSON
    newCheckout,           // override with LaunchDarkly value
    darkMode,
  };
}

const config = validateTenantsConfig(baseConfig);
// → createTenantRegistry(config)  — flags now include LD values
Split.io example
import { SplitFactory } from '@splitsoftware/splitio';
import {
  loadTenantsConfig,
  validateTenantsConfig,
} from '@multitenant/config';

const factory = SplitFactory({
  core: { authorizationKey: process.env.SPLIT_API_KEY! },
});
const splitClient = factory.client();
await new Promise<void>((resolve) => splitClient.on(splitClient.Event.SDK_READY, resolve));

const baseConfig = await loadTenantsConfig();

for (const [tenantKey, tenant] of Object.entries(baseConfig.tenants)) {
  const newCheckout = splitClient.getTreatment(tenantKey, 'new-checkout') === 'on';
  const darkMode    = splitClient.getTreatment(tenantKey, 'dark-mode')    === 'on';

  tenant.flags = {
    ...tenant.flags,
    newCheckout,
    darkMode,
  };
}

const config = validateTenantsConfig(baseConfig);
// → createTenantRegistry(config)  — flags now include Split values
Why sync into flags?
  • Single APIisTenantFeatureEnabled and useTenantFlag work the same regardless of where flag values originate.
  • Edge-safe — external SDKs are Node-only; syncing once at boot means Edge middleware and React client code still read plain boolean flags without importing heavy SDKs.
  • Testable — unit tests only need to set flags in the config fixture; no mocking of external SDKs.
  • Incremental migration — start with static JSON flags, then swap in LaunchDarkly or Split for specific flags without changing consumer code.

Experiments

Top-level experiments define A/B tests available across the app. Each key is an experiment id.

Experiment fields

FieldTypeRequiredDescription
defaultVariantstringYesThe variant used when no override is active (e.g. "control").
variantsstring[]YesAll possible variant names (minimum one). Must include defaultVariant.
descriptionstringNoHuman-readable description of the experiment.

Tenant overrides — tenants can override experiments in their experiments field:

FieldTypeDescription
forcedVariantstringPin this tenant to a specific variant (e.g. "new").
enabledbooleanEnable or disable the experiment for this tenant.
"experiments": {
  "checkout-v2": {
    "description": "Test the new checkout flow",
    "defaultVariant": "control",
    "variants": ["control", "new"]
  }
}

Defaults

The optional defaults object provides global settings:

FieldTypeDescription
environmentEnvironmentNameDefault environment ("local", "development", "staging", "production").
localDomainTemplatestringTemplate for auto-generating local domains (e.g. "{tenant}.localhost"). The {tenant} placeholder is replaced with the tenant key.

Config merge

Order: markets[*].configtenants[*].configtenants[*].configByEnvironment[environment] (for the active environment).

  • Deep merge for nested plain objects; scalars / arrays are replaced (last wins).
  • Conflict: at the same path, one layer has a plain object and another a non-objectInvalidTenantsConfigError.

Effective config for app code: getTenantConfig(registry, tenantKey, environment?) or useTenantConfig() in React.

Example — support is deep-merged; tier is overridden last for production:

{
  "markets": {
    "us": {
      "currency": "USD",
      "locale": "en-US",
      "timezone": "America/New_York",
      "config": { "support": { "tier": "standard" } }
    }
  },
  "tenants": {
    "acme": {
      "market": "us",
      "domains": { "local": { "acme.localhost": "acme" } },
      "config": { "support": { "email": "help@acme.test" } },
      "configByEnvironment": {
        "production": { "support": { "tier": "priority" } }
      }
    }
  }
}

After merge for acme in production: support.tierpriority, support.emailhelp@acme.test.

Validation (hard errors)

  • Every tenants[*].market exists in markets
  • No overlapping domain patterns within the same environment
  • Config merge must not hit object vs non-object clashes
  • Tenant experiments overrides must reference top-level experiments

Always run npx @multitenant/cli check in CI alongside your JSON edits.

On this page