Payload Tenant Display: Persistent Admin Banner Guide
TypeScript + React guide to display the current tenant in Payload admin dashboard with cookie monitoring and logo.

📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
I was working on a multi-tenant Payload CMS project when I realized users couldn't easily identify which tenant they were currently working with. The Payload admin interface handles tenant switching behind the scenes, but there was no visual indicator showing the active tenant. After implementing a clean banner component, I now have a persistent display that shows tenant logo, name, and domain link. This guide shows you exactly how to create and integrate this component.
Setting Up the Tenant Display Component
The first step is to create a client component that can read tenant cookies and update automatically when the tenant changes. This component will live in your admin dashboard and provide constant visibility of the current tenant context.
// File: src/components/payload/custom/TenantDisplay.tsx
'use client';
import React, { useEffect, useState } from 'react';
import { getAllTenants } from '@/payload/db/index';
import isMediaObject, { getFirstMedia } from '@/payload/utilities/images/isMediaObject';
import { PayloadImageClient } from '@/components/payload/images/payload-image-client';
import styles from './TenantDisplay.module.scss';
/**
* Client Component: Displays the current tenant in the Payload admin dashboard
* Reads the payload-tenant cookie to determine the current tenant
* Updates when the tenant is switched
*/
export default function TenantDisplay() {
const [tenants, setTenants] = useState<any[]>([]);
const [currentTenantId, setCurrentTenantId] = useState<string | number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Helper function to get the cookie value
const getCookieValue = (name: string): string | null => {
if (typeof document === 'undefined') return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
const cookieValue = parts.pop()?.split(';').shift();
return cookieValue || null;
}
return null;
};
useEffect(() => {
const loadTenantData = async () => {
try {
setLoading(true);
setError(null);
// Fetch all tenants
const allTenants = await getAllTenants();
if (!allTenants || allTenants.length === 0) {
setError('No tenants found');
return;
}
setTenants(allTenants);
// Get the current tenant ID from the cookie
const tenantCookie = getCookieValue('payload-tenant');
if (tenantCookie) {
setCurrentTenantId(tenantCookie);
} else {
// If there's no cookie, default to the first tenant
setCurrentTenantId(allTenants[0].id);
}
} catch (err) {
console.error('Error loading tenant information:', err);
setError('Unable to load tenant information');
} finally {
setLoading(false);
}
};
loadTenantData();
// Set up a listener to detect cookie changes
const checkCookieChange = () => {
const tenantCookie = getCookieValue('payload-tenant');
const newTenantId = tenantCookie || (tenants.length > 0 ? tenants[0].id : null);
if (newTenantId?.toString() !== currentTenantId?.toString()) {
loadTenantData();
}
};
// Check for cookie changes every 2 seconds
const interval = setInterval(checkCookieChange, 2000);
// Also check when the window gains focus (the user might have switched tabs)
const handleFocus = () => {
checkCookieChange();
};
window.addEventListener('focus', handleFocus);
// Also listen for storage events (for cross-tab communication)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'payload-tenant') {
checkCookieChange();
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
clearInterval(interval);
window.removeEventListener('focus', handleFocus);
window.removeEventListener('storage', handleStorageChange);
};
}, [currentTenantId]);
if (loading) {
return (
<div className={styles.container}>
<p>Loading tenant information...</p>
</div>
);
}
if (error) {
return (
<div className={styles.error}>
<p>{error}</p>
</div>
);
}
const currentTenant = tenants.find(t => t.id.toString() === currentTenantId?.toString()) || tenants[0];
// Get the logo media object if it exists
const logoMedia = currentTenant?.logo && isMediaObject(currentTenant.logo) ? currentTenant.logo : null;
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.leftSection}>
{logoMedia && (
<div className={styles.logoContainer}>
<PayloadImageClient
image={logoMedia}
width={32}
height={32}
className={styles.logo}
style={{ maxWidth: '32px', maxHeight: '32px' }}
/>
</div>
)}
<span className={styles.tenantName}>{currentTenant.name}</span>
</div>
{currentTenant.domain && (
<a
href={`https://${currentTenant.domain}`}
target="_blank"
rel="noopener noreferrer"
className={styles.domainLink}
>
{currentTenant.domain}
<svg
className={styles.externalIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M7 17L17 7M17 7H7M17 7V17" />
</svg>
</a>
)}
</div>
</div>
);
}
This component uses a client-side approach to monitor the payload-tenant cookie and automatically updates the UI when the tenant changes. It reads all available tenants, determines which one is active based on the cookie, and displays the relevant information in a clean, compact format.
Creating the Styling Module
To make the component visually consistent with the Payload admin interface, you'll need to create a matching SCSS module that uses Payload's design tokens:
// File: src/components/payload/custom/TenantDisplay.module.scss
@import '~@payloadcms/ui/scss';
.container {
padding: calc(var(--base) * 1);
margin-bottom: calc(var(--base) * 2);
background: linear-gradient(135deg, var(--theme-elevation-50) 0%, var(--theme-elevation-75) 100%);
border: 1px solid var(--theme-elevation-200);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: calc(var(--base) * 1);
}
.leftSection {
display: flex;
align-items: center;
gap: calc(var(--base) * 1);
}
.logoContainer {
flex-shrink: 0;
}
.logo {
border-radius: 4px;
overflow: hidden;
max-width: 32px;
max-height: 32px;
width: auto;
height: auto;
object-fit: contain;
}
.tenantName {
font-size: 1.125rem;
font-weight: 700;
color: var(--theme-elevation-900);
}
.domainLink {
display: inline-flex;
align-items: center;
gap: calc(var(--base) * 0.25);
padding: calc(var(--base) * 0.5) calc(var(--base) * 0.75);
background-color: var(--theme-primary-100);
color: var(--theme-primary-700);
border-radius: 4px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
background-color: var(--theme-primary-200);
color: var(--theme-primary-800);
}
}
.externalIcon {
width: 12px;
height: 12px;
opacity: 0.7;
}
.error {
padding: calc(var(--base) * 1.5);
background-color: var(--theme-error-100);
border: 1px solid var(--theme-error-300);
border-radius: 4px;
color: var(--theme-error-700);
font-size: 0.875rem;
p {
margin: 0;
}
}
The styling uses Payload's design tokens to ensure that the component looks native within the admin interface. The narrow banner layout maximizes space efficiency while clearly displaying the tenant information.
Registering the Component in the Payload Admin
To display your component in the Payload admin dashboard, you need to register it as a custom component in the Payload configuration:
// File: payload.config.ts
admin: {
user: Users.slug,
components: {
beforeDashboard: ['/src/components/payload/custom/TenantDisplay'],
},
// ... rest of admin config
},
The beforeDashboard property places your component at the top of the admin dashboard, where users will see it immediately upon logging in. This is the perfect location for a tenant indicator since it provides constant visibility without cluttering the interface.
Ensuring Proper Data Loading
For your component to work correctly, the getAllTenants utility function must properly populate the logo field and bypass access restrictions:
// File: src/payload/db/index.ts (update the getAllTenants function)
export const getAllTenants = async () => {
return await unstable_cache(
async () => {
const payload = await getPayloadClient();
const result = await payload.find({
collection: "tenants",
limit: 100,
depth: 1, // Populate the logo relation
overrideAccess: true, // Bypass access control
});
return result.docs;
},
[CACHE_KEY.TENANT_BY_ID('all')],
{
tags: [TAGS.TENANTS],
revalidate: false,
},
)();
};
The key settings here are depth: 1 to populate the logo relationship and overrideAccess: true to ensure that the component can fetch tenant data regardless of the current user's permissions level.
Understanding the Component Flow
The component follows a clear sequence:
- Fetch all available tenants with populated logos
- Read the
payload-tenantcookie to determine the active tenant - Find the matching tenant object
- Check if the logo is a valid Media object using a type guard
- Display the tenant information in a compact banner format
- Monitor for cookie changes and automatically refresh the display
This approach ensures that users always know which tenant they're working with, and the component updates in real-time when they switch tenants using Payload's built-in tenant selector.
Conclusion
By implementing this custom TenantDisplay component, you provide users with clear, persistent visibility of their current tenant context. The component automatically responds to tenant changes and displays tenant logos, names, and domain links in a clean, compact banner that fits naturally within the Payload admin interface.
The combination of cookie monitoring, proper media handling, and Payload design tokens creates a professional solution that enhances the multi-tenant admin experience without disrupting existing workflows.
Let me know in the comments if you have questions about implementing this component in your own Payload multi-tenant project, and subscribe for more practical development guides.
Thanks, Matija