Active Tenant State Management & Admin Display in Payload CMS

Implement robust active tenant switching with cookie-based state management

·Updated on:·Matija Žiberna·
Active Tenant State Management & Admin Display in Payload CMS

📚 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.

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 consists of three parts:

  1. A backend endpoint to set the active tenant cookie when a user switches tenants.
  2. A frontend component to read this cookie and display the tenant in the admin UI.
  3. Middleware integration to ensure the state is consistent across the application.

Let's build this step by step.

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:

  1. Scoping Globals: Fetching global settings specific to the active tenant.
  2. Preview Mode: Determining which tenant's content to preview.
  3. 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:

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

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.

You might be interested in