BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Payload Per-Tenant Theming: Ultimate Admin Styling

Payload Per-Tenant Theming: Ultimate Admin Styling

Apply per-tenant admin themes with @payloadcms/plugin-multi-tenant and useTenantSelection, syncing a data-tenant…

1st August 2026·Updated on:10th August 2026··
Payload
Payload Per-Tenant Theming: Ultimate Admin Styling

Evaluating Payload CMS Implementation Costs?

Scope design, content structure, and migration hours to estimate a realistic production timeline and hosting setup.

Try the Cost EstimatorGet a Second Opinion

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The Data Flow
  • 1. Read Tenant Selection from the Official API
  • 2. Build a Central Tenant-to-Theme Mapping
  • 3. Sync the Selection onto the DOM
  • 4. Load a Custom Stylesheet
  • 5. Write the CSS Against Theme Variables
  • 6. Test the Fallback and Edge Cases
  • Worked Example
  • FAQ
  • Wrapping Up
On this page:
  • The Data Flow
  • 1. Read Tenant Selection from the Official API
  • 2. Build a Central Tenant-to-Theme Mapping
  • 3. Sync the Selection onto the DOM
  • 4. Load a Custom Stylesheet
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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:

code
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 sidebar logo guide:

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 sidebar logo guide's step 4 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:

VariableWhat it controls
--theme-bgThe main Admin background (html, .template-default)
--theme-elevation-0 through -1000Surface 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