Managing Active Tenant State in Payload CMS Multi-Tenant Applications with Next.js Cookies

Implement robust active tenant switching with cookie-based state management

·Matija Žiberna·
Managing Active Tenant State in Payload CMS Multi-Tenant Applications with Next.js Cookies

📚 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 I hit a frustrating wall. The @payloadcms/plugin-multi-tenant plugin handled the data isolation beautifully, but I couldn't figure out where to store the "current active tenant" state. My admin users needed to switch between multiple tenants they managed, and every approach I tried had serious flaws.

Storing it in the database felt like overkill for what's essentially session data. Client-side state disappeared on page refreshes and wasn't accessible server-side where I needed it most. Local storage couldn't be read during server-side rendering or in middleware. After wrestling with this for days, I discovered that Next.js cookies were the perfect solution - server-side accessible, persistent across requests, and lightweight enough for real-time tenant switching.

This guide walks you through implementing a robust active tenant state management system that works seamlessly with Payload CMS multi-tenant architecture, from the backend utilities to the admin interface components.

The Challenge with Payload CMS Multi-Tenant State

When you're building with Payload CMS and the multi-tenant plugin, you quickly realize that while the plugin handles data isolation perfectly, it doesn't solve the problem of tracking which tenant a user is currently working with. Most tutorials and examples hardcode tenant access like user.tenants[0], which breaks down immediately when users manage multiple tenants.

I needed a solution that could:

  • Persist across server-side operations and page refreshes
  • Be accessible in middleware for request routing
  • Work with Payload globals and collections seamlessly
  • Provide a clean admin interface for tenant switching
  • Handle fallback scenarios gracefully

The answer turned out to be Next.js cookies combined with a smart priority system.

The foundation of this approach is a utility function that implements a smart fallback hierarchy for determining the active tenant. This ensures your application always knows which tenant context to operate in, even when users haven't explicitly selected one.

// File: src/utilities/getActiveTenant.ts
import type { PayloadRequest } from 'payload'
import type { Tenant, User } from '@payload-types'

/**
 * Gets the active tenant for the current user based on cookies and user permissions
 * Priority: active-tenant cookie > payload-tenant cookie > first user tenant
 */
export const getActiveTenant = async (req: PayloadRequest): Promise<Tenant | null> => {
  if (!req.user || !req.user.tenants) {
    return null
  }

  // Helper function to get cookie value
  const getCookie = (name: string): string | null => {
    const cookies = req.headers?.get('cookie') || ''
    const match = cookies.match(new RegExp(`(^| )${name}=([^;]+)`))
    return match ? match[2] : null
  }

  // Try active-tenant cookie first (user-selected)
  let targetTenantId = getCookie('active-tenant')
  
  // Fallback to payload-tenant cookie (domain-based)
  if (!targetTenantId) {
    targetTenantId = getCookie('payload-tenant')
  }

  // If we have a target tenant ID, verify user has access and fetch it
  if (targetTenantId) {
    const hasAccess = req.user.tenants.some(({ tenant }) => {
      const id = typeof tenant === 'object' ? tenant.id : tenant
      return String(id) === String(targetTenantId)
    })

    if (hasAccess) {
      try {
        const tenantDoc = await req.payload.findByID({
          collection: 'tenants',
          id: targetTenantId,
        })
        return tenantDoc
      } catch (error) {
        req.payload.logger.error({ err: error }, 'Error fetching active tenant by ID')
      }
    }
  }

  // Fallback: use first tenant from user's tenant list
  if (req.user.tenants.length > 0) {
    try {
      const firstTenant = req.user.tenants[0].tenant
      const tenantId = typeof firstTenant === 'object' ? firstTenant.id : firstTenant
      
      const tenantDoc = await req.payload.findByID({
        collection: 'tenants',
        id: tenantId,
      })
      return tenantDoc
    } catch (error) {
      req.payload.logger.error({ err: error }, 'Error fetching fallback tenant')
    }
  }

  return null
}

/**
 * Gets the active tenant slug - convenience function
 */
export const getActiveTenantSlug = async (req: PayloadRequest): Promise<string> => {
  const tenant = await getActiveTenant(req)
  return tenant?.slug || 'default'
}

This utility implements a three-tier priority system that ensures your Payload CMS multi-tenant application always has a valid tenant context. The active-tenant cookie takes precedence when users explicitly select a tenant, while the payload-tenant cookie serves as a domain-based fallback, and the user's first tenant provides the ultimate safety net.

The beauty of this approach is that it works seamlessly with both Payload globals and collections. Any server-side operation can call getActiveTenant(req) to get the current tenant context, making it perfect for access control, data filtering, and preview functionality.

Creating the Backend Endpoint for Tenant Selection

To allow users to switch their active tenant, you need an endpoint that can set the active-tenant cookie securely. This endpoint handles authentication, permission verification, and cookie management in one clean operation.

// File: src/collections/Tenants/endpoints/setActiveTenant.ts
import type { PayloadRequest, Endpoint } from 'payload'
import { generateCookie, getCookieExpiration, mergeHeaders } from 'payload'

export const setActiveTenant: Endpoint = {
  path: '/set-active/:tenantId',
  method: 'post',
  handler: async (req: PayloadRequest) => {
    const { tenantId } = req.routeParams || {}
    
    if (!tenantId) {
      return Response.json({ error: 'Tenant ID is required' }, { status: 400 })
    }

    // Verify user has access to this tenant
    if (!req.user || !req.user.tenants) {
      return Response.json({ error: 'User not authenticated' }, { status: 401 })
    }

    const hasAccess = req.user.tenants.some(({ tenant }) => {
      const id = typeof tenant === 'object' ? tenant.id : tenant
      return String(id) === String(tenantId)
    })

    if (!hasAccess) {
      return Response.json({ error: 'No access to this tenant' }, { status: 403 })
    }

    // Set the active-tenant cookie
    const tenantCookie = generateCookie({
      name: 'active-tenant',
      value: String(tenantId),
      expires: getCookieExpiration({ seconds: 30 * 24 * 60 * 60 }), // 30 days
      path: '/',
      returnCookieAsObject: false,
    })

    // Set response headers
    const headers = new Headers({
      'Set-Cookie': tenantCookie as string,
    })

    req.responseHeaders = req.responseHeaders
      ? mergeHeaders(req.responseHeaders, headers)
      : headers

    return Response.json({ 
      success: true, 
      message: 'Active tenant updated',
      tenantId 
    })
  },
}

This endpoint leverages Payload's built-in cookie utilities to ensure proper security headers and expiration settings. The 30-day expiration means users won't lose their tenant selection between sessions, while the path setting ensures the cookie is available across your entire admin interface.

Next, register this endpoint in your Tenants collection configuration:

// File: src/collections/Tenants/index.ts
import { setActiveTenant } from './endpoints/setActiveTenant'

export const Tenants: CollectionConfig = {
  slug: 'tenants',
  // ... other configuration
  endpoints: [setActiveTenant],
  // ... rest of configuration
}

The endpoint integrates seamlessly with Payload's existing authentication and permission system, ensuring that users can only switch to tenants they actually have access to. This maintains the security model of your multi-tenant application while providing the flexibility users need.

Building the Admin Interface Component

The admin interface component provides a clean, intuitive way for users to see their current tenant status and switch when needed. This component uses Payload's native UI components to maintain consistency with the rest of the admin interface.

// File: src/collections/Tenants/components/ActiveTenantSelector.tsx
'use client'

import React, { useState } from 'react'
import { useConfig, useAuth, Button, toast, useDocumentInfo } from '@payloadcms/ui'

interface Tenant {
  id: string | number
  name: string
  slug: string
}

const ActiveTenantSelector: React.FC = () => {
  const [isLoading, setIsLoading] = useState(false)
  const { config } = useConfig()
  const { user } = useAuth()
  const { id } = useDocumentInfo()

  // Get current active tenant from cookie
  const getActiveTenantId = () => {
    if (typeof document === 'undefined') return null
    const cookies = document.cookie.split(';')
    const activeTenantCookie = cookies.find(cookie => 
      cookie.trim().startsWith('active-tenant=')
    )
    return activeTenantCookie ? activeTenantCookie.split('=')[1] : null
  }

  const activeTenantId = getActiveTenantId()
  const currentTenantId = String(id)
  const isCurrentTenantActive = activeTenantId === currentTenantId

  // Check if user has access to this tenant
  const hasAccess = user?.tenants?.some(({ tenant }) => {
    const tenantId = typeof tenant === 'object' ? tenant.id : tenant
    return String(tenantId) === currentTenantId
  })

  if (!user || !hasAccess) {
    return null // Don't show component if user doesn't have access
  }

  const handleSetActive = async () => {
    setIsLoading(true)
    
    try {
      const response = await fetch(`${config.serverURL}/api/tenants/set-active/${currentTenantId}`, {
        method: 'POST',
        credentials: 'include',
        headers: {
          'Content-Type': 'application/json',
        },
      })

      if (response.ok) {
        toast.success(`Active tenant updated successfully`)
        // Refresh the page to update the UI
        window.location.reload()
      } else {
        toast.error('Failed to set active tenant')
      }
    } catch (error) {
      console.error('Error setting active tenant:', error)
      toast.error('Error setting active tenant')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div style={{ 
      padding: '12px', 
      backgroundColor: '#f9f9f9', 
      border: '1px solid #e0e0e0',
      borderRadius: '4px',
      marginBottom: '16px'
    }}>
      <h4 style={{ margin: '0 0 8px 0', fontSize: '14px' }}>Active Tenant Status</h4>
      
      {isCurrentTenantActive ? (
        <div style={{ color: '#22c55e', fontSize: '12px' }}>
          ● This is your currently ACTIVE tenant
        </div>
      ) : (
        <div>
          <div style={{ fontSize: '12px', color: '#666', marginBottom: '8px' }}>
            This tenant is not currently active
          </div>
          <Button
            onClick={handleSetActive}
            disabled={isLoading}
            size="small"
            buttonStyle="primary"
          >
            {isLoading ? 'Setting as Active...' : 'Make This Tenant Active'}
          </Button>
        </div>
      )}
    </div>
  )
}

export default ActiveTenantSelector

This component uses useDocumentInfo() to get the current tenant's ID from the URL, then compares it against the active tenant cookie to determine the current state. The component only renders for users who have access to the current tenant, maintaining security while providing a clean interface.

To integrate this component into your Tenants collection, add it as a UI field in the sidebar:

// File: src/collections/Tenants/index.ts (field configuration)
{
  name: 'activeTenantActions',
  type: 'ui',
  admin: {
    position: 'sidebar',
    components: {
      Field: '@/collections/Tenants/components/ActiveTenantSelector#default',
    },
  },
},

The UI field type is perfect for this use case because it doesn't affect your database schema while providing the interactive functionality users need. After adding the component, run npm run generate:importmap to register it with Payload's import system.

Integrating with Payload CMS Preview and Globals

One of the most powerful applications of this cookie-based tenant state system is integrating it with Payload's preview functionality. When users preview content, they expect it to reflect the tenant context they're currently working in.

// File: src/app/(frontend)/next/preview/route.ts
import { getActiveTenantSlug } from '@/utilities/getActiveTenant'

export async function GET(request: NextRequest): Promise<Response> {
  const { searchParams } = request.nextUrl
  const path = searchParams.get('path') || '/'
  const secret = searchParams.get('secret')

  // Validate secret and authenticate user
  const user = await payload.auth({ req: request })
  
  if (!user.user) {
    draft.disable()
    return new Response('You are not allowed to preview this page', { status: 403 })
  }

  // Extract active tenant slug using the new utility
  // This respects user's active tenant selection via cookies
  let tenantSlug = 'default' // fallback
  
  try {
    tenantSlug = await getActiveTenantSlug(request as unknown as PayloadRequest)
  } catch (error) {
    payload.logger.error({ err: error }, 'Error fetching active tenant for preview')
    // tenantSlug remains 'default'
  }

  draft.enable()

  // Construct the tenant-specific path
  const redirectPath = `/tenant-slugs/${tenantSlug}${path}`

  redirect(redirectPath)
}

This integration ensures that when users click "Preview" in the admin interface, they see the content in the context of their currently selected tenant. The preview respects the cookie-based tenant selection automatically, without requiring any additional configuration.

The same pattern works beautifully with Payload globals. You can use getActiveTenant() in your globals access functions to ensure that global content like site settings, navigation, or theme configurations are tenant-aware:

// Example: Tenant-aware globals access
access: {
  read: async ({ req }) => {
    const activeTenant = await getActiveTenant(req)
    return activeTenant ? { tenant: { equals: activeTenant.id } } : false
  }
}

This approach makes your entire Payload CMS multi-tenant application consistent, ensuring that every operation respects the user's current tenant context.

Middleware Integration and Advanced Usage

The cookie-based approach shines when integrated with Next.js middleware, where you need reliable access to tenant context for routing decisions and request processing. Since cookies are available in middleware through the request headers, you can make tenant-aware routing decisions at the edge.

// File: src/middleware.ts (example integration)
import { NextRequest, NextResponse } from 'next/server'

export async function middleware(req: NextRequest) {
  const activeTenantCookie = req.cookies.get('active-tenant')
  const tenantId = activeTenantCookie?.value

  if (tenantId) {
    // Use tenant context for routing decisions
    const requestHeaders = new Headers(req.headers)
    requestHeaders.set('X-Active-Tenant-ID', tenantId)
    
    return NextResponse.next({
      request: { headers: requestHeaders }
    })
  }

  // Handle requests without active tenant
  return NextResponse.next()
}

This middleware pattern allows you to pass tenant context through to your application routes, making it available for server-side operations, API routes, and page components. The X-Active-Tenant-ID header can then be read by your Payload operations to ensure consistent tenant context throughout your application.

For Payload globals that need to be tenant-aware, you can create helper functions that automatically apply the active tenant context:

// File: src/utilities/tenantAwareGlobals.ts
export const getActiveTenantGlobal = async (
  globalSlug: string, 
  req: PayloadRequest
) => {
  const activeTenant = await getActiveTenant(req)
  
  if (!activeTenant) {
    throw new Error('No active tenant found')
  }

  return await req.payload.findGlobal({
    slug: globalSlug,
    where: {
      tenant: { equals: activeTenant.id }
    }
  })
}

This pattern ensures that your PayloadCMS multi-tenant plugin integration remains consistent across globals, collections, and custom operations.

Conclusion

Managing active tenant state in Payload CMS multi-tenant applications doesn't have to be complicated. By leveraging Next.js cookies as the storage mechanism, you get the perfect balance of persistence, server-side accessibility, and lightweight implementation. The cookie-based approach integrates seamlessly with Payload's multi-tenant plugin architecture, providing reliable tenant context for globals, collections, preview functionality, and middleware operations.

This solution eliminates the common pitfalls of hardcoded tenant access while providing users with an intuitive interface for switching between the tenants they manage. The smart fallback hierarchy ensures your application always has valid tenant context, even when cookies are missing or invalid.

You now have a robust foundation for building multi-tenant applications with Payload CMS that scale with your users' needs. The cookie-based state management works across server and client boundaries, making it perfect for the complex requirements of modern multi-tenant SaaS applications.

Let me know in the comments if you have questions about implementing this in your own Payload CMS multi-tenant project, and subscribe for more practical development guides.

Thanks, Matija

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