---
title: "Payload Per-Tenant Theming: Ultimate Admin Styling"
slug: "payload-per-tenant-theming-admin"
published: "2026-08-01"
updated: "2026-08-10"
validated: "2026-08-10"
categories:
  - "Payload"
tags:
  - "Payload per-tenant theming"
  - "@payloadcms/plugin-multi-tenant"
  - "useTenantSelection"
  - "data-tenant attribute"
  - "Payload admin theming"
  - "TenantThemeSync"
  - "tenant theme map"
  - "color-mix CSS"
  - "accent-color"
  - "Payload Next.js"
  - "admin CSS override"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "@payloadcms/plugin-multi-tenant@latest"
  - "payload@latest"
  - "next.js@15"
  - "react@18"
  - "typescript@5"
  - "scss@latest"
status: "stable"
llm-purpose: "Payload per-tenant theming: step-by-step guide to use @payloadcms/plugin-multi-tenant and useTenantSelection to add scoped admin CSS. Implement the…"
llm-prereqs:
  - "Access to @payloadcms/plugin-multi-tenant"
  - "Access to Payload CMS"
  - "Access to Next.js"
  - "Access to React"
  - "Access to TypeScript"
llm-outputs:
  - "Completed outcome: Payload per-tenant theming: step-by-step guide to use @payloadcms/plugin-multi-tenant and useTenantSelection to add scoped admin CSS. Implement the…"
---

**Summary Triples**
- (sourceOfTruth, is, useTenantSelection() from @payloadcms/plugin-multi-tenant)
- (tenantSyncComponent, writes, selected tenant id to data-tenant attribute on <html> (or <body>))
- (stylesheet, keysOff, the data-tenant attribute to scope admin CSS per tenant)
- (implementation, avoids, URL parsing, cookies, or duplicate tenant-selection mechanisms)
- (fallbackBehavior, preserves, stock Payload look when no tenant is selected or tenant id is unknown)
- (themeValues, shouldBeMapped, tenant id → CSS custom properties (e.g., --accent))
- (visualEffects, canUse, color-mix() and accent-color to create subtle background tints and brand accents)
- (adminUI, isNotModified, navigation, forms or collection views beyond styling)

### {GOAL}
Payload per-tenant theming: step-by-step guide to use @payloadcms/plugin-multi-tenant and useTenantSelection to add scoped admin CSS. Implement the…

### {PREREQS}
- Access to @payloadcms/plugin-multi-tenant
- Access to Payload CMS
- Access to Next.js
- Access to React
- Access to TypeScript

### {STEPS}
1. Read tenant selection with hook
2. Create tenant-to-theme mapping
3. Sync selection to DOM attribute
4. Load custom admin stylesheet
5. Scope CSS to theme variables
6. Test fallbacks and edge cases

<!-- llm:goal="Payload per-tenant theming: step-by-step guide to use @payloadcms/plugin-multi-tenant and useTenantSelection to add scoped admin CSS. Implement the…" -->
<!-- llm:prereq="Access to @payloadcms/plugin-multi-tenant" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:output="Completed outcome: Payload per-tenant theming: step-by-step guide to use @payloadcms/plugin-multi-tenant and useTenantSelection to add scoped admin CSS. Implement the…" -->

# Payload Per-Tenant Theming: Ultimate Admin Styling
> Payload per-tenant theming: step-by-step guide to use @payloadcms/plugin-multi-tenant and useTenantSelection to add scoped admin CSS. Implement the…
Matija Žiberna · 2026-08-01

Making the Payload Admin UI react visually to whichever tenant is selected comes down to reading tenant state from `useTenantSelection()`, mirroring that value onto a `data-tenant` attribute on `<html>`, and letting a custom stylesheet key off that attribute. No second tenant-selection mechanism, no URL parsing, no extra cookie. This guide walks through the full setup: a subtle background tint, a brand accent color, and the fallback behavior that keeps unselected or unknown tenants looking like stock Payload.

I built this for a multi-brand client project running several tenants through `@payloadcms/plugin-multi-tenant`, where each brand needed its own accent color in the Admin panel without touching Payload's navigation, forms, or collection views. The pattern below is what shipped, including the edge cases that came up during testing.

## The Data Flow

Before the code, it helps to see the whole chain in one place:

```
Payload's tenant selector (@payloadcms/plugin-multi-tenant)
        ↓
useTenantSelection()  ← the only source of truth, official client API
        ↓
a small "sync" client component
        ↓
sets an attribute on <html> (or <body>)
        ↓
a custom stylesheet keys off that attribute
```

Every step below maps to one link in that chain.

## 1. Read Tenant Selection from the Official API

`@payloadcms/plugin-multi-tenant` already exposes a client hook for this. Reach for it directly instead of building a parallel tenant-tracking mechanism:

```ts
// File: use-tenant-theme-key.ts
import { useTenantSelection } from "@payloadcms/plugin-multi-tenant/client";

const { selectedTenantID, options } = useTenantSelection();
```

`selectedTenantID` is the id of the currently selected tenant, or `undefined` if none is selected. `options` is an array of `{ label, value }` pairs, where `value` is the tenant's id and `label` comes from whatever field the tenant collection's `admin.useAsTitle` is set to, typically `name`.

One limitation worth knowing up front: `options` only exposes `useAsTitle` and `id`. It doesn't include a tenant's `slug` or any other field. If you need a stable, CSS-safe key and your `useAsTitle` value isn't one, keep a small lookup table mapping label to key, covered in the next step, rather than fetching extra tenant data over the network on every switch.

A few things to skip entirely here. Don't read the tenant from the URL unless your app's routing already does that for other reasons. Don't add a second cookie or localStorage preference. Don't build a second `<select>` for switching tenants. Payload's own selector already handles all of this.

## 2. Build a Central Tenant-to-Theme Mapping

One small file keeps this easy to extend as tenants get added:

```ts
// File: tenant-theme-map.ts

/** sites.name (the selector's label) -> theme key used in CSS/data attrs */
export const TENANT_THEME_KEY_BY_NAME: Record<string, string> = {
  "Tenant One": "tenant-one",
  "Tenant Two": "tenant-two",
};
```

```ts
// File: use-tenant-theme-key.ts
"use client";
import { useTenantSelection } from "@payloadcms/plugin-multi-tenant/client";
import { TENANT_THEME_KEY_BY_NAME } from "./tenant-theme-map";

export function useTenantThemeKey(): string | undefined {
  const { options, selectedTenantID } = useTenantSelection();
  const selected = options.find((o) => o.value === selectedTenantID);
  return selected ? TENANT_THEME_KEY_BY_NAME[String(selected.label)] : undefined;
}
```

A tenant that isn't in the map yet, whether renamed or newly added before its theme exists, returns `undefined`. That's the safe fallback this hook is designed to produce, not an error state to catch separately.

## 3. Sync the Selection onto the DOM

With the hook in place, the next piece is a component that renders nothing and only sets or clears an attribute. This lives in Payload's `beforeNavLinks` slot, the same slot covered in the <a href="./payload-admin-sidebar-logo.md#1-pick-the-right-injection-slot">sidebar logo guide</a>:

```tsx
// File: TenantThemeSync.tsx
"use client";
import { useEffect } from "react";
import { useTenantThemeKey } from "./use-tenant-theme-key";

export function TenantThemeSync() {
  const themeKey = useTenantThemeKey();

  useEffect(() => {
    if (themeKey) {
      document.documentElement.setAttribute("data-tenant", themeKey);
    } else {
      document.documentElement.removeAttribute("data-tenant");
    }
  }, [themeKey]);

  return null;
}
```

Register it the same way as any other custom Admin component:

```ts
// File: payload.config.ts
admin: {
  components: {
    beforeNavLinks: ["/path/to/TenantThemeSync#TenantThemeSync"],
  },
}
```

Run `payload generate:importmap` after adding it. The <a href="./payload-admin-sidebar-logo.md#4-regenerate-the-import-map">sidebar logo guide's step 4</a> covers why this step exists and what happens if you skip it.

Because this component reads React context through `useTenantSelection`, the attribute updates immediately when the tenant selector changes, with no page reload involved. It also survives a hard refresh, since Payload persists the selected tenant itself through a cookie. This component just mirrors whatever Payload has already resolved.

## 4. Load a Custom Stylesheet

The Payload Next.js integration doesn't expose an `admin.css` or `admin.scss` config option. Instead, import a stylesheet directly in your Admin root layout, right after Payload's own CSS so yours can override it:

```tsx
// File: src/app/(payload)/layout.tsx
import "@payloadcms/next/css";
import "./custom.css";
```

Plain `.css` handles this without any extra dependencies, and Next's built-in CSS pipeline supports nesting natively. Reach for `.scss` only if the `sass` package is already part of your setup and you specifically want SCSS features.

## 5. Write the CSS Against Theme Variables

Payload's compiled CSS exposes a set of theme custom properties designed for exactly this kind of override, so there's no need to fight internal selectors. You can confirm current variable names for your installed version by checking `node_modules/@payloadcms/next/dist/prod/styles.css` or the dev equivalent shipped with `@payloadcms/next/css`. As of writing, the relevant ones are:

| Variable | What it controls |
|---|---|
| `--theme-bg` | The main Admin background (`html`, `.template-default`) |
| `--theme-elevation-0` through `-1000` | Surface and text grayscale ramp |
| `--theme-success-*` / `--theme-error-*` | Semantic colors reserved for save and error feedback |

Scope every override by both your tenant attribute and Payload's own `data-theme` attribute (`light` or `dark`), so dark mode stays intact instead of getting forced into a light-mode background:

```css
[data-tenant="tenant-one"] {
  --tenant-accent: #123456;
}

html[data-theme="light"][data-tenant="tenant-one"] {
  --theme-bg: color-mix(in srgb, var(--tenant-accent) 4%, white);
}

html[data-theme="dark"][data-tenant="tenant-one"] {
  --theme-bg: color-mix(in srgb, var(--tenant-accent) 12%, black);
}
```

`color-mix()` keeps this readable and easy to tune. Adjusting the percentage is simpler than hand-computing a new hex value for every tenant, and support across evergreen browsers is solid.

For the accent color itself, lean on a small number of stable, native or Payload-owned hooks rather than recoloring individual buttons one at a time:

```css
/* Tints native checkboxes/radios for free, no deep selectors needed */
[data-tenant] {
  accent-color: var(--tenant-accent);
}

/* Existing sidebar border, just tinted */
[data-tenant] .nav {
  border-color: var(--tenant-accent);
}

/* box-shadow, not border, so it never shifts padding or layout */
[data-tenant] .nav__link.active {
  color: var(--tenant-accent);
  box-shadow: inset 3px 0 0 0 var(--tenant-accent);
}
```

Steer clear of selectors shaped like `div > div:nth-child(2) > ...`. Anything tied to incidental DOM structure tends to break on the next Payload upgrade. Stable, human-readable classnames like `.nav`, `.nav__link`, and `.tenant-selector` hold up far better, and checking Payload's own compiled CSS or source for a class is a more reliable way to confirm it's stable than guessing from devtools.

## 6. Test the Fallback and Edge Cases

A handful of states are worth checking explicitly once the theming is in place.

With no tenant selected, such as on the login screen, initial load, or a platform-wide view like Account settings, confirm no `data-tenant` attribute gets set and the Admin panel looks like stock Payload. An unresolved tenant should never default to one specific brand.

For an unknown or future tenant not yet in your map, confirm nothing breaks. It should render exactly like the no-tenant-selected state.

If `admin.theme` isn't restricted to `'light'`, users can toggle dark mode, so test both. Keep dark-mode tints subtle, and avoid forcing a light-mode background color into dark mode.

When switching tenants live, confirm the change applies instantly, with no reload, across the dashboard, a list view, and an edit view. Switching back and forth repeatedly shouldn't leave stale styling behind, since the `useEffect` in step 3 re-runs on every value change and handles its own cleanup.

On a hard refresh mid-tenant, the theme should still match, since it comes from Payload's own persisted selection rather than anything stored on your side.

## Worked Example

- Mapping: `src/payload/admin-components/tenant-theme/tenant-theme-map.ts`
- Shared hook: `use-tenant-theme-key.ts`
- Sync component: `TenantThemeSync.tsx`
- Stylesheet: `src/app/(payload)/custom.css`
- Root layout import: `src/app/(payload)/layout.tsx`
- Full CanPrev, Orange Naturals, and Cytomatrix write-up with actual brand colors and sources: `docs/admin-tenant-theming.md`

## FAQ

**Can I get a tenant's slug from `useTenantSelection()`?**
No. The `options` array only exposes `useAsTitle` and `id`. If you need a CSS-safe key, keep a small label-to-key lookup table instead of fetching extra tenant fields at runtime.

**Do I need to store the selected tenant myself?**
No. Payload already persists the selection through its own cookie. The sync component in step 3 only mirrors that value onto the DOM, so a hard refresh stays correctly themed without any extra storage.

**Why key off `data-theme` as well as `data-tenant`?**
Scoping overrides by both attributes keeps light and dark mode independent of tenant branding. Without it, a tenant's light-mode background color can end up forced into dark mode.

**What happens if a tenant isn't in the theme map yet?**
The hook returns `undefined`, no `data-tenant` attribute gets set, and the Admin panel falls back to its stock appearance. That's expected behavior, not a bug to guard against.

**Can I reuse `--theme-success` or `--theme-error` for brand colors?**
Don't. Those variables are load-bearing for save and error feedback throughout the Admin panel. Use a dedicated `--tenant-accent` variable instead.

## Wrapping Up

Per-tenant theming in Payload's Admin panel comes down to five pieces that chain together: Payload's own `useTenantSelection()` hook, a small label-to-key mapping, a sync component that mirrors the selection onto a DOM attribute, a stylesheet loaded after Payload's own CSS, and CSS scoped to theme variables instead of fragile selectors. Test the no-tenant and unknown-tenant fallbacks early, since those are the states most likely to surface bugs once real tenants get added.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Payload per-tenant theming: step-by-step guide to use @payloadcms/plugin-multi-tenant and useTenantSelection to add scoped admin CSS. Implement the…",
  "responses": [
    {
      "question": "What does the article \"Payload Per-Tenant Theming: Ultimate Admin Styling\" cover?",
      "answer": "Payload per-tenant theming: step-by-step guide to use @payloadcms/plugin-multi-tenant and useTenantSelection to add scoped admin CSS. Implement the…"
    }
  ]
}
```