Multi-tenant themes
One deployed theme can serve many tenants. Each tenant activates it on its own domain, and the platform resolves which tenant a request belongs to from the host. You do not deploy a copy per customer.
That's a large operational win and a specific class of bug. Three rules keep you on the right side of it.
1. Read the tenant per request
The tenant is a property of the request, not of the process. A deployment handling requests for shop-a.example.com and shop-b.example.com at the same time has two tenants in flight simultaneously.
So resolve it from the event, every time:
export default defineEventHandler(async (event) => {
const tenant = getTenant(event) // per request
const config = useRuntimeConfig(event)
return await $fetch(`${config.revenexxApiUrl}/v1/products`, {
headers: {
'X-Revenexx-Tenant': tenant,
'X-Revenexx-Api-Key': config.revenexxApiKey,
},
})
})
What breaks this: a module-scoped variable.
// ✗ Wrong: resolved once, at module load, then shared by every request
const tenant = process.env.NUXT_REVENEXX_TENANT
export default defineEventHandler(async () => {
return fetchProducts(tenant) // serves the first tenant's data to everyone
})
A single-tenant deployment can get away with NUXT_REVENEXX_TENANT from the environment. A multi-tenant one cannot — and the failure is silent until the second tenant goes live, which is the worst time to find it.
2. Partition every cache by tenant
The API guarantees isolation. Your caching can undo that guarantee, and this is by far the most common way cross-tenant leakage actually happens on a storefront.
Anything you cache — a catalog response, a category tree, a branding payload, a menu, a rendered page fragment — must have the tenant in its key:
// ✗ One tenant's categories served to every tenant
const categories = await useStorage('cache').getItem('categories')
// ✓ Keyed by tenant
const categories = await useStorage('cache').getItem(`categories:${tenant}`)
The same applies to Nitro route rules, to any in-process memo, and to useState:
// ✓ Tenant in the state key
const branding = useState(`branding:${tenant}`, () => null)
A branding cache keyed only by "branding" will serve one customer's colours to another. It is the classic version of this bug because branding feels like a per-deployment concern, and it isn't.
The habit worth building: when you write a cache key, write the tenant into it in the same keystroke. Adding it later means auditing every key you already wrote.
3. Keep cross-request state in useState
Nuxt's useState is request-scoped on the server and hydrates cleanly to the client. That makes it the right home for anything that needs to survive across components within one request — the resolved tenant, its branding, the buyer's context.
// ✓ Request-scoped, and hydrates to the client
const tenantContext = useState('tenant-context', () => resolveTenantContext())
What must not hold per-request state:
| Don't | Why |
|---|---|
A module-level let or object | Shared by every concurrent request in the process. |
A module-level Map used as a cache | Same, unless the key includes the tenant. |
| A singleton client configured with one tenant | Configure the tenant per call, not per client. |
Server utilities that need the current request can reach it through Nitro's per-request async context rather than threading the event through every signature — which is how the base layer's platform client picks up the tenant without every function taking an event parameter.
Where per-tenant differences show up
Four places, in roughly the order you'll hit them:
Branding. Colours, typography, logo. Fetch during SSR so the first byte carries the right brand, cache per tenant. See Design tokens.
Content. Pages, menus and media are per tenant, so the pages delivery call and anything you cache from it are per tenant too.
Catalog and pricing. Different tenants have different assortments and different contract prices, so nothing in the commerce path is shareable across tenants — not even "the product list", which looks stable and isn't.
Configuration. Feature switches and checkout requirements vary per customer. Drive them from tenant-scoped configuration rather than from build-time constants, or you're back to one deployment per customer.
The checkout session secret
The stateless checkout session token is signed with a secret from runtime config. On a deployment serving more than one tenant, set it explicitly:
NUXT_CHECKOUT_SESSION_SECRET=<a-long-random-value>
Leaving it unset is survivable on a single-tenant site and not on a shared one. Set it per deployment as a secret environment variable.
Testing for the bug before a customer finds it
Cross-tenant leakage is invisible with one tenant configured, which is why it reaches production. Two cheap checks:
- Run two hosts against one dev server. Point two local hostnames at the same process, load a page on each, and confirm the second doesn't show the first's data. Most cache-key bugs surface immediately.
- Grep your cache keys. Every
getItem,setItem,useStateand route-rule key in your theme should have the tenant in it or a defensible reason not to. This takes ten minutes and is the highest-value review you can do on a multi-tenant theme.
Next steps
- Authentication — the tenant header, and why it's mandatory.
- The BFF pattern — where the tenant gets attached to a platform call.
- Design tokens — branding per tenant without a rebuild.
- Publishing a theme — installing one theme on many tenants.
- Theme tokens and styling — the tutorial, with the per-tenant branding route written out.