DEV Community

Kate Johnson
Kate Johnson

Posted on

Serving many brands from one Nuxt 3 codebase

I work on a white-label storefront that ships under a couple dozen brands. Same flows, same components, different logos, colors, copy, API credentials, and feature sets. One codebase serves all of them, and the difference between brands is data, not code.

That last sentence is the whole architecture. Everything below is the mechanics of making it true in Nuxt 3, using two fictional brands, acme and globex, as stand-ins.

tenant config is data

Every brand gets one JSON file. Not a branch, not an env file full of one-off variables, not a folder of overridden components. A config file with a schema.

// config/tenants/acme.json
{
  "id": "acme",
  "name": "Acme Fiber",
  "domains": ["shop.acme.example", "acme.localhost"],
  "theme": {
    "colorPrimary": "220 90% 45%",
    "colorAccent": "32 95% 55%",
    "radius": "0.5rem",
    "logo": "/logos/acme.svg"
  },
  "flags": {
    "giftCards": true,
    "scheduledInstall": false,
    "liveChat": true
  },
  "api": {
    "baseUrl": "https://api.internal.example/acme"
  }
}
Enter fullscreen mode Exit fullscreen mode

globex.json has the same shape and different values. A Zod schema validates every file at build time, so a typo in a brand config fails the build instead of failing one brand's checkout on a Tuesday.

The discipline this buys you is that adding a brand becomes a data entry task. Our record from "signed contract" to "staging site up" is under a day, and none of that day was spent writing components.

Notice what's not in the file. No secrets. The config gets shipped to the client for theming and flags, so credentials live elsewhere, which is the next section.

runtimeConfig holds the secrets

Nuxt's runtimeConfig splits into private keys, available only server-side, and a public block that reaches the browser. Per-tenant API credentials go in the private part.

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    tenantApiKeys: '', // JSON map, injected at deploy time
    public: {
      defaultTenant: 'acme',
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

The values here are placeholders. At runtime, Nuxt overrides them from environment variables using the NUXT_ naming convention, so NUXT_TENANT_API_KEYS populates tenantApiKeys without a rebuild. That property is load-bearing for multi-tenant work. We build one artifact, and the same image runs in every environment with different env vars.

There are two deployment shapes for multi-brand Nuxt, and it's worth being explicit about which you're in because it changes where secrets live.

One process per brand. Each brand gets its own deployment with its own env vars, and the tenant is fixed at boot. Simple, strong isolation, more infrastructure to run. This is what we do for brands with contractual isolation requirements.

One process, many brands. A single deployment serves every domain and resolves the tenant per request. Cheaper to operate, and credentials become a keyed map like tenantApiKeys above rather than flat env vars. Most of our traffic runs this way.

The config-as-data approach supports both, which is convenient when a brand needs to move from the shared pool to a dedicated deployment. That has happened twice, and both times it was an infra change, not a code change.

resolving the tenant in Nitro middleware

In the shared-process shape, some code has to decide which brand a request belongs to, and it has to happen before any handler runs. Nitro server middleware is built for this. Files in server/middleware/ run on every request, and their job is to decorate event.context, not to return responses.

// server/middleware/tenant.ts
import { tenants } from '~~/config/tenants';

export default defineEventHandler((event) => {
  const host = getRequestHost(event, { xForwardedHost: true });
  const tenant = tenants.find((t) => t.domains.includes(host));

  if (!tenant) {
    throw createError({ statusCode: 404, statusMessage: 'Unknown host' });
  }

  event.context.tenant = tenant;

  const { tenantApiKeys } = useRuntimeConfig(event);
  const keys = JSON.parse(tenantApiKeys) as Record<string, string>;
  event.context.tenantApiKey = keys[tenant.id];
});
Enter fullscreen mode Exit fullscreen mode

A few notes on the details. getRequestHost and createError are h3 utilities, auto-imported in the server directory. The xForwardedHost option matters when you sit behind a load balancer, and it's only safe because our ingress strips and resets that header. If yours doesn't, trusting it means anyone can impersonate a brand with a curl flag. Verify before you copy that line.

Passing event to useRuntimeConfig(event) is the documented form in server handlers, and it keeps per-request config features working.

Downstream, every server route reads identity and credentials from context instead of computing them.

// server/api/offers.get.ts
export default defineEventHandler(async (event) => {
  const { tenant, tenantApiKey } = event.context;

  return $fetch(`${tenant.api.baseUrl}/offers`, {
    headers: { 'x-api-key': tenantApiKey },
  });
});
Enter fullscreen mode Exit fullscreen mode

The route has no idea how tenancy gets resolved, and that's the point. When we later added a header-based override for internal preview environments, one middleware file changed.

For the client side, a small plugin exposes the sanitized tenant (theme, flags, copy, never credentials) through useState, so components call useTenant() and stay ignorant of the resolution mechanics.

theming with CSS custom properties

Brand theming fails in one of two directions. Either you compile a CSS bundle per brand, and builds get slow and cache-hostile, or you sprinkle :class bindings everywhere, and the design system erodes. CSS custom properties avoid both. One stylesheet, per-request values.

The tenant plugin injects the theme as variables on the root element during SSR.

// plugins/theme.server.ts
export default defineNuxtPlugin(() => {
  const tenant = useTenant();
  const t = tenant.value.theme;

  useHead({
    htmlAttrs: {
      style: [
        `--color-primary: ${t.colorPrimary}`,
        `--color-accent: ${t.colorAccent}`,
        `--radius: ${t.radius}`,
      ].join(';'),
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

Because this happens server-side, the first paint is already branded. No flash of default theme, no client-side style swap.

Tailwind then maps its tokens onto the variables instead of hardcoded values.

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: 'hsl(var(--color-primary) / <alpha-value>)',
        accent: 'hsl(var(--color-accent) / <alpha-value>)',
      },
      borderRadius: {
        brand: 'var(--radius)',
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Components write bg-primary and rounded-brand and never learn which brand they're rendering. Designers still work in the token vocabulary they know. The <alpha-value> trick keeps Tailwind's opacity modifiers working, which is why the config stores raw HSL channels rather than a complete color string.

The limit of this approach is structural variation. Variables handle colors, spacing, radii, and fonts well. They do not handle "globex wants the checkout steps in a different order." For structure we use flags and, rarely, per-tenant component slots. If a brand needs a genuinely different page, that's a product conversation, not a theming problem.

feature flags without tenant names

The failure mode to guard against is this line spreading through the codebase.

if (tenant.id === 'acme') { ... }
Enter fullscreen mode Exit fullscreen mode

One of these is harmless. Fifty of them mean brand behavior is defined by scavenger hunt, and every new brand starts by grepping for a competitor's name. The rule we enforce in review is that tenant ids appear in config files and tests, never in application logic.

Instead, behavior keys off named capabilities in the config's flags block, read through one composable.

// composables/useFlag.ts
export function useFlag(flag: keyof TenantFlags): boolean {
  const tenant = useTenant();
  return tenant.value.flags[flag] ?? false;
}
Enter fullscreen mode Exit fullscreen mode
<GiftCardBanner v-if="useFlag('giftCards')" />
Enter fullscreen mode Exit fullscreen mode

The difference from the if (acme) version looks cosmetic and isn't. A flag names the behavior, so the code reads as "this brand sells gift cards" rather than "this brand is acme, whatever that implies this week." Onboarding a brand means answering yes/no questions in a config file. And when a flag is finally true for everyone, you delete it and the branch it guarded, which is much harder when the condition is a list of brand names someone has to research.

Typing flags as a closed interface, not Record<string, boolean>, keeps the flag list honest. Adding a flag is a deliberate schema change with a default, and dead flags show up as unused keys.

per-tenant smoke tests

Everything above has an uncomfortable side effect. Code coverage no longer implies brand coverage. Your test suite can pass completely while globex.json points at a decommissioned API host, because configs are data and data doesn't compile.

Most of our production incidents in this app have been config-shaped, not code-shaped. A wrong domain in one brand's file. A flag enabled for a brand whose backend didn't support the feature yet. Unit tests catch none of that.

So the CI stage that has earned its keep is a smoke matrix. For every tenant config, boot the app resolved to that tenant, then hit a short list of golden-path checks. Home page renders with that brand's logo and primary color variable. The offers endpoint returns 200 through that brand's credentials. Checkout loads. Flag-gated pages return 200 where the flag is on and 404 where it's off. In Playwright this is one spec file and a loop over the config directory, with the tenant selected per project via its domain alias.

It's not deep testing, maybe thirty seconds per brand, and it converts "works on the brands we happened to check" into "works on all of them" on every PR. When a config edit breaks a single brand, the failing job names the brand before review does.

If you take one thing from this post, take the shape rather than the specifics. Push every brand difference into validated config, resolve identity once in server middleware, express look and behavior through tokens and flags that never mention a brand by name, and smoke test each config because it is now part of your program. Nuxt makes the mechanics pleasant, but the payoff comes from the discipline, and the discipline is what lets brand twenty-four cost a day instead of a sprint.

Top comments (0)