Brand a Theme with design tokens

Drive a Theme's look with CSS custom-property design tokens, brand per tenant at runtime, and support light and dark — no rebuild per customer.

A Theme's look is data, not code. You define design tokens as CSS custom properties, components read those tokens instead of hard-coded values, and each tenant's brand is applied at runtime. The same build serves every customer; a rebrand is a token change, not a redeploy.

This page is for frontend developers and designer-developers styling a Theme. Prerequisites: a scaffolded Theme, see Build your first Theme. The token concepts, the shipped palette and the theme presets are reference material on Design tokens; this guide is the per-tenant branding walkthrough.

Why tokens

A multi-tenant Theme is deployed once and serves many customers. Baked-in colors would force a fork or a rebuild per customer, so define the brand on :root, have components reference var(--…), and swap the values per tenant at runtime. The reasoning and the three kinds of variable on the page are on Design tokens.

1. Define the tokens

Put the token set in one place — a single stylesheet that is the source of truth for the Theme's design. Name tokens for their role, not their value, so they survive a rebrand. Your Theme may already declare tokens through Tailwind's @theme layer; see Design tokens and keep one source of truth.

app/assets/css/tokens.css
:root {
  color-scheme: light dark;

  /* Color */
  --color-primary: #4f46e5;
  --color-primary-hover: #4338ca;
  --color-surface: #ffffff;
  --color-surface-muted: #f9fafb;
  --color-text: #1f2937;
  --color-text-muted: #6b7280;
  --color-border: #e5e7eb;

  /* Typography */
  --font-family-base: 'Inter', system-ui, sans-serif;
  --font-size-base: 1rem;
  --line-height-base: 1.5;

  /* Shape and spacing */
  --radius-md: 0.5rem;
  --spacing-unit: 0.25rem;
  --container-max-width: 1280px;
}

Keep the set small. Define twenty or thirty core tokens and derive the rest with color-mix and calc rather than hand-maintaining a hundred literals.

2. Consume tokens in components

Components reference tokens, never raw values. That's the whole contract — change a token, every component that reads it updates.

app/components/Button.vue
<template>
  <button class="btn-primary"><slot /></button>
</template>

<style scoped>
.btn-primary {
  background: var(--color-primary);
  color: #fff;
  border-radius: var(--radius-md);
  padding: calc(var(--spacing-unit) * 3) calc(var(--spacing-unit) * 6);
  font-family: var(--font-family-base);
}
.btn-primary:hover {
  background: var(--color-primary-hover);
}
</style>

This works for Blökkli blocks too. A block can do the same. The hero block from Build a block sets its background from its own bgHex value; to make it follow the tenant's brand, fall back to var(--color-primary) when no preset or custom color is chosen.

3. Override tokens per tenant at runtime

On a deployed Theme the platform resolves the tenant from the request host and injects the tenant context per request. Fetch the tenant's branding during server-side rendering and inject the token overrides as inline styles, so the very first byte already carries the right brand — no flash of the default palette.

Add a server-side API route that returns the tenant's branding tokens:

server/api/branding.get.ts
export default defineEventHandler(async (event) => {
  // getTenantBranding is your own server function (see below).
  const branding = await getTenantBranding(event)

  // Map the tenant's branding onto your token names.
  return {
    cssVars: {
      '--color-primary': branding.primaryColor,
      '--color-primary-hover': branding.primaryColorHover,
      '--font-family-base': branding.fontFamily,
    },
    logoUrl: branding.logoUrl,
  }
})

getTenantBranding(event) is yours to write; the platform does not ship a function of that name. Its contract: read the tenant the platform resolved for this request, return { primaryColor, primaryColorHover, fontFamily, logoUrl } for that tenant, and fall back to your default token values when the tenant has none. Where the branding data lives is your decision. A Theme that extends @revenexx/cover-theme inherits a plugin that resolves the tenant's logo, navigation and appearance once per request, so check Modules and plugins before you build a second source. Whichever source you use, key every cache by tenant (see Multi-tenant themes).

Apply the returned overrides at the root of the app during SSR:

app/app.vue
<script setup lang="ts">
// Fetched during SSR so the branding is in the first byte (no flash of default).
const { data: branding } = await useFetch('/api/branding')

const rootStyle = computed(() =>
  Object.entries(branding.value?.cssVars ?? {})
    .map(([key, value]) => `${key}: ${value}`)
    .join('; '),
)
</script>

<template>
  <div :style="rootStyle">
    <NuxtPage />
  </div>
</template>

Tenant tokens change rarely, so fetch them once and cache aggressively server-side rather than on every render. Because the values differ per tenant, partition any cache by tenant — a branding cache keyed only by "branding" would serve one tenant's colors to another.

4. Serve per-tenant logos and assets

Branding is more than color. Serve each tenant's logo, favicon, and social images from the branding payload rather than bundling one customer's assets into the Theme:

app/components/SiteLogo.vue
<script setup lang="ts">
const { data: branding } = await useFetch('/api/branding')
</script>

<template>
  <img :src="branding?.logoUrl" alt="Storefront logo" width="160" height="40" />
</template>

5. Support light and dark

Set color-scheme: light dark on :root (done in step 1) and provide dark overrides. The CSS for following the system preference and for an attribute-driven toggle is on Design tokens. Scope the system-preference block so an explicit choice wins:

app/assets/css/tokens.css
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --color-surface: #111827;
    --color-text: #f3f4f6;
  }
}

[data-theme='dark'] {
  --color-surface: #111827;
  --color-text: #f3f4f6;
}

An explicit toggle has the same flash risk as client-side branding: if you set data-theme after hydration, a returning visitor sees the wrong mode for a moment. Restore the saved choice with a small inline script in <head>, which runs before first paint, and let the component only flip and save it:

app/app.vue
useHead({
  script: [{
    // Runs before first paint, so a saved choice never flashes the other mode.
    innerHTML: "try{var t=localStorage.getItem('theme');if(t)document.documentElement.setAttribute('data-theme',t)}catch(e){}",
    tagPosition: 'head',
  }],
})
app/components/ThemeToggle.vue
<script setup lang="ts">
const theme = ref<'light' | 'dark'>('light')

onMounted(() => {
  // Read what the head script already applied; do not set it again here.
  theme.value = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'
})

function toggle() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
  document.documentElement.setAttribute('data-theme', theme.value)
  try { localStorage.setItem('theme', theme.value) } catch {}
}
</script>

<template>
  <button type="button" :aria-pressed="theme === 'dark'" @click="toggle">
    Switch to {{ theme === 'dark' ? 'light' : 'dark' }} mode
  </button>
</template>

Test both modes before you ship. A contrast regression is easy to miss in one mode and obvious in the other.

Troubleshooting

  • Brand flashes from default to custom on load. You're applying the override client-side. Fetch the branding during SSR and inline it on the root element.
  • One tenant sees another's colors. A branding cache keyed without the tenant. Partition the cache by tenant.
  • A saved dark choice flashes light on load. The data-theme attribute is set after hydration. Move it into the inline <head> script above.
  • Dark mode misses some surfaces. A component uses a raw color instead of a token. Replace it with the matching var(--…).

Where to go next

Was this page helpful?