Active Tenant State Management & Admin Display in Payload CMS
Implement robust active tenant switching with cookie-based state management

📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
Related Posts:
I was deep into building a multi-tenant SaaS platform with Payload CMS when 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, leading to confusion and costly mistakes when editing content for the wrong organization.
In this guide, I'll share how I solved this by implementing a cookie-based state management system and a visual Tenant Display component that keeps users context-aware at all times.
The Challenge with Payload CMS Multi-Tenant Visibility
When managing multiple tenants in Payload CMS, one of the most critical usability issues is ensuring administrators can clearly identify which organization's data they're currently working with. The built-in tenant switching functionality works silently in the background, but without visual confirmation, users can:
- Accidentally edit content for wrong tenant
- Spend time checking tenant context before making changes
- Create content that's associated with incorrect organization
- Experience cognitive load from constant context-switching
This problem becomes especially severe when:
- Users manage 5+ tenants with similar names
- Team members work across multiple tenant contexts
- Critical updates need to be made quickly without errors
A visual tenant indicator is essential for preventing costly mistakes in multi-tenant SaaS applications.
The Solution: Cookie-Based State & Visual Components
The solution consists of three parts:
- A backend endpoint to set the active tenant cookie when a user switches tenants.
- A frontend component to read this cookie and display the tenant in the admin UI.
- Middleware integration to ensure the state is consistent across the application.
Let's build this step by step.
1. The Backend: Setting the Tenant Cookie
First, we need a way to persist the user's selection. Storing it in the database feels like overkill for session data, so we'll use a cookie. This allows both the client (admin UI) and the server (middleware/API) to access the current tenant ID.
We'll create a custom endpoint in Payload that sets a payload-tenant cookie whenever a user selects a tenant.
// File: src/endpoints/tenant/setTenant.ts
import { PayloadHandler } from 'payload'
import { cookies } from 'next/headers'
export const setTenant: PayloadHandler = async (req) => {
const { tenantId } = await req.json()
const cookieStore = await cookies()
// Set the cookie with a long expiry
cookieStore.set('payload-tenant', tenantId, {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production'
})
return Response.json({ success: true })
}
Register this endpoint in your Payload config:
// File: payload.config.ts
export default buildConfig({
// ...
endpoints: [
{
path: '/api/tenant/set',
method: 'post',
handler: setTenant,
}
]
})
Now, whenever a user switches tenants, we can call this endpoint to update the state.
2. The Frontend: Tenant Display Component
Next, we need a visual component in the admin dashboard to show which tenant is active. This component will read the cookie we just set and display the tenant's logo and name.
The Display Component
Here is the React component that fetches the tenant details and renders the banner:
// 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) return parts.pop()?.split(';').shift() || null;
return null;
};
useEffect(() => {
const fetchTenants = async () => {
try {
// 1. Get all tenants (using a server action or API call)
const allTenants = await getAllTenants();
setTenants(allTenants);
// 2. Read the cookie
const tenantCookie = getCookieValue('payload-tenant');
setCurrentTenantId(tenantCookie);
} catch (err) {
console.error('Error fetching tenant data:', err);
setError('Failed to load tenant info');
} finally {
setLoading(false);
}
};
fetchTenants();
// Optional: Listen for cookie changes if you have a sophisticated event system
// Or simply rely on page reloads/navigations which is common in Payload admin
}, []);
if (loading) return null;
// Find the active tenant object
const activeTenant = tenants.find(t => t.id === currentTenantId || t.id === Number(currentTenantId));
if (!activeTenant) return null;
return (
<div className={styles.tenantBanner}>
<div className={styles.tenantContent}>
{activeTenant.logo && isMediaObject(activeTenant.logo) && (
<div className={styles.tenantLogo}>
<PayloadImageClient
media={activeTenant.logo}
width={24}
height={24}
alt={activeTenant.title}
/>
</div>
)}
<span className={styles.tenantName}>{activeTenant.title}</span>
{/* Link to the public site for this tenant if applicable */}
<a
href={`http://${activeTenant.slug}.localhost:3000`}
target="_blank"
rel="noopener noreferrer"
className={styles.visitLink}
>
View Site →
</a>
</div>
</div>
);
}
The Helper Utility
To make the above component work, we need a way to fetch all tenants efficiently:
// File: src/payload/db/index.ts (or wherever you keep DB helpers)
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { unstable_cache } from 'next/cache';
/**
* Fetches all tenants with caching.
* Uses unstable_cache to avoid hitting the DB on every render.
*/
export const getAllTenants = () => {
return unstable_cache(
async () => {
const payload = await getPayload({ config: configPromise });
const result = await payload.find({
collection: 'tenants',
limit: 100,
depth: 1, // Populate the logo relation
overrideAccess: true, // Bypass access control
});
return result.docs;
},
['all-tenants-cache-key'],
{
tags: ['tenants'],
revalidate: false,
},
)();
};
Styling
Add some clean styles to make it look native to the Payload admin:
// File: src/components/payload/custom/TenantDisplay.module.scss
.tenantBanner {
background-color: var(--theme-elevation-100);
border-bottom: 1px solid var(--theme-elevation-200);
padding: 0.5rem 1rem;
width: 100%;
}
.tenantContent {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: var(--theme-elevation-800);
}
.tenantLogo {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
overflow: hidden;
background: var(--theme-elevation-50);
img {
object-fit: contain;
}
}
.tenantName {
font-weight: 600;
}
.visitLink {
margin-left: auto;
font-size: 0.75rem;
color: var(--theme-success-500);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
3. Integration with Payload Components
Finally, inject this component into your Payload admin UI. We usually place it in the global header or before the dashboard metrics.
// File: payload.config.ts
export default buildConfig({
// ...
admin: {
components: {
beforeDashboard: ['@/components/payload/custom/TenantDisplay'],
},
},
})
Integrating with Server-Side Logic
Having the cookie is great for the UI, but you also need to use it in your server-side logic, such as in API routes or Payload Hooks.
You can create a helper to retrieve the active tenant on the server:
// File: src/utilities/getActiveTenant.ts
import { cookies } from 'next/headers'
export async function getActiveTenant() {
const cookieStore = await cookies()
const tenantId = cookieStore.get('payload-tenant')?.value
if (!tenantId) return null
return tenantId
}
This helper becomes incredibly useful for:
- Scoping Globals: Fetching global settings specific to the active tenant.
- Preview Mode: Determining which tenant's content to preview.
- Middleware Service: Routing requests based on the established session.
Conclusion
By implementing this custom TenantDisplay component backed by a robust cookie-based state management system, you provide users with clear, persistent visibility of their current tenant context.
This solution eliminates the common pitfalls of hardcoded tenant access while providing users with an intuitive interface. The component automatically displays tenant logos, names, and domain links in a clean, compact banner that fits naturally within the Payload admin interface.
You now have a robust foundation for identifying and managing active tenants across both your frontend UI and backend logic.
Next Steps:
- Learn how to Configure Tenant-Specific Globals using this state system.
- Detailed setup for your Local Development Environment to test these flows.


