---
title: "Serve Multiple Payload CMS Collections from One Route"
slug: "payload-cms-multiple-collections-one-route"
published: "2025-12-18"
updated: "2026-07-19"
categories:
  - "Payload"
tags:
  - "Payload multiple collections route"
  - "Next.js route disambiguation"
  - "payload cms page vs post route"
  - "multi-tenant route disambiguation"
  - "payload route handler orchestration"
llm-intent: "how-to"
audience-level: "intermediate"
llm-purpose: "Payload CMS multiple collections: disambiguate slugs and serve events + workshops from one /events/[slug] route using route detection and direct…"
llm-prereqs:
  - "Payload CMS"
  - "Next.js"
  - "TypeScript"
  - "React"
---

**Summary Triples**
- (Serve Multiple Payload CMS Collections from One Route, expresses-intent, how-to)
- (Serve Multiple Payload CMS Collections from One Route, covers-topic, Payload multiple collections route)
- (Serve Multiple Payload CMS Collections from One Route, provides-guidance-for, Payload CMS multiple collections: disambiguate slugs and serve events + workshops from one /events/[slug] route using route detection and direct…)

### {GOAL}
Payload CMS multiple collections: disambiguate slugs and serve events + workshops from one /events/[slug] route using route detection and direct…

### {PREREQS}
- Payload CMS
- Next.js
- TypeScript
- React

### {STEPS}
1. Add direct collection verifiers
2. Implement route detection functions
3. Add handler verification logic
4. Orchestrate handlers in page
5. Test and extend for more collections

<!-- llm:goal="Payload CMS multiple collections: disambiguate slugs and serve events + workshops from one /events/[slug] route using route detection and direct…" -->
<!-- llm:prereq="Payload CMS" -->
<!-- llm:prereq="Next.js" -->
<!-- llm:prereq="TypeScript" -->
<!-- llm:prereq="React" -->

# Serve Multiple Payload CMS Collections from One Route
> Payload CMS multiple collections: disambiguate slugs and serve events + workshops from one /events/[slug] route using route detection and direct…
Matija Žiberna · 2025-12-18

> **Note:** While this guide uses tenant context (common in multi-tenant apps), the pattern applies to any scenario where multiple content types share a URL path. If you're building a multi-tenant app and need this pattern, ensure you have the [basic multi-tenant setup](/blog/production-ready-multi-tenant-nextjs-payload) working first.

## Introduction

I was building a client's event management site when I hit an interesting architectural challenge. They had two distinct content types—public events and interactive workshops—with completely different data structures and admin interfaces. The business requirement was straightforward: both should be accessible from `/events/[slug]` for SEO and user experience reasons. Technically, putting them on separate routes like `/events` and `/workshops` would've been cleaner, but business needs don't always align with technical elegance.

After implementing a working solution, I realized this pattern solves a broader problem that many developers face when building Payload CMS sites with complex content hierarchies. This guide walks you through the exact implementation I developed, covering route detection, collection verification, and handler orchestration. By the end, you'll understand how to let business requirements drive your architecture while keeping the technical implementation clean.

---

## The Problem: Same Path, Different Collections

When you have multiple content collections that should appear on the same URL path, you need a way to disambiguate which collection owns a given slug. In Next.js with Payload CMS, this isn't automatic—you have to build the logic yourself.

Here's the scenario we'll implement:

- **Events**: Traditional event listings with dates, locations, capacity
- **Workshops**: Interactive workshop listings with skill level, prerequisites, instructor info

Both need to be accessible via `/events/[slug]`, but they have completely different data structures in Payload CMS. From the user's perspective, there's no distinction—they're all just "events." From your code's perspective, you need to determine which collection each slug belongs to before rendering the appropriate template.

---

## Architecture Overview

The solution involves three main components:

**1. Route Detection Functions**
These determine whether a URL pattern could potentially be handled by a handler (before hitting the database). For example, "events accessed directly" vs "events nested under categories."

**2. Collection Verification Functions**
These are non-cached database checks that verify a slug actually exists in a specific collection. They're the ultimate source of truth for disambiguation.

**3. Handler Orchestration**
The main page component calls handlers in sequence, skipping those that return `null`, until one successfully renders content.

Here's the flow:

```
Request: /events/blockchain-intro
  ↓
Main page component
  ↓
Try Event handler
  ├─ Route detection: ✓ (matches /events/*)
  ├─ Verify exists in events collection: ✓
  └─ Render event template

Request: /events/solidity-workshop
  ↓
Try Event handler
  ├─ Route detection: ✓ (matches /events/*)
  ├─ Verify exists in events collection: ✗ (not in events)
  └─ Return null
  ↓
Try Workshop handler
  ├─ Route detection: ✓ (matches /events/*)
  ├─ Verify exists in workshops collection: ✓
  └─ Render workshop template
```

---

## Step 1: Create Collection Verification Functions

The foundation of this pattern is having lightweight, non-cached functions that check if a slug exists in each collection. These use direct database queries with tenant filtering.

In your Payload database layer file (`src/payload/db/index.ts`), add verification functions for each collection:

```typescript
// File: src/payload/db/index.ts

/**
 * Non-cached verification that an event exists by slug
 * Used for route disambiguation - direct database lookup
 * Cannot use Next.js unstable_cache outside request context
 */
export const checkEventExistsDirect = async (
  slug: string,
  tenant: string
): Promise<boolean> => {
  const payload = await getPayloadClient();
  const where: any = { slug: { equals: slug } };

  if (tenant) {
    where["tenant.slug"] = { equals: tenant };
  }

  const { docs } = await payload.find({
    collection: "event",
    where,
    select: {
      slug: true, // Only fetch slug for minimal database load
    },
    limit: 1,
  });

  return docs.length > 0;
};

/**
 * Non-cached verification that a workshop exists by slug
 */
export const checkWorkshopExistsDirect = async (
  slug: string,
  tenant: string
): Promise<boolean> => {
  const payload = await getPayloadClient();
  const where: any = { slug: { equals: slug } };

  if (tenant) {
    where["tenant.slug"] = { equals: tenant };
  }

  const { docs } = await payload.find({
    collection: "workshop",
    where,
    select: {
      slug: true,
    },
    limit: 1,
  });

  return docs.length > 0;
};
```

These functions are intentionally not cached. Why? They're used during route handler logic—which might occur in different contexts (middleware, API routes, background jobs)—where Next.js's `unstable_cache` doesn't work. They're also lightweight queries fetching only the slug field, so the performance impact is minimal.

---

## Step 2: Create Route Detection Functions

Route detection functions determine whether a URL pattern could be handled by a handler. These are synchronous checks that don't hit the database.

Create separate handler files for each content type:

```typescript
// File: src/app/(frontend)/events/[...slug]/_event-handler.tsx

/**
 * Detects if slug matches event route patterns
 * Events are accessible via:
 * - /events/[slug] - direct access
 * - /events/category/[name]/[slug] - category browsing
 * - /events/location/[name]/[slug] - location browsing
 */
export function isEventRoute(slug: string[]): boolean {
  // Direct access: /events/slug (2 segments)
  if (slug.length === 1) {
    return true;
  }

  // Categorized: /events/category/name/slug (4 segments minimum)
  if (slug.length >= 4 && slug[1] === "category") {
    return true;
  }

  // By location: /events/location/name/slug (4 segments minimum)
  if (slug.length >= 4 && slug[1] === "location") {
    return true;
  }

  return false;
}

/**
 * Extracts the event slug from various route patterns
 * Always the last segment of the URL
 */
export function getEventSlugFromRoute(slug: string[]): string {
  return slug[slug.length - 1];
}
```

And the workshop handler:

```typescript
// File: src/app/(frontend)/events/[...slug]/_workshop-handler.tsx

/**
 * Detects if slug matches workshop route patterns
 * Workshops are accessible via:
 * - /events/[slug] - direct access (but workshop-specific)
 * - /events/instructor/[name]/[slug] - instructor browsing
 */
export function isWorkshopRoute(slug: string[]): boolean {
  // Direct access: /events/slug (2 segments)
  // But only if it's not caught by event routes with more specificity
  // This will be disambiguated in the verification step
  if (slug.length === 1) {
    return true;
  }

  // By instructor: /events/instructor/name/slug (4 segments)
  if (slug.length >= 4 && slug[1] === "instructor") {
    return true;
  }

  return false;
}

export function getWorkshopSlugFromRoute(slug: string[]): string {
  return slug[slug.length - 1];
}
```

Notice that both handlers accept the same basic pattern (`/events/[slug]`). That's intentional—the route detection is permissive. The verification step (next) is where we disambiguate.

---

## Step 3: Create Verification Functions in Handlers

Each handler has a verification function that checks if the slug belongs to its collection. These use the database functions from Step 1.

```typescript
// File: src/app/(frontend)/events/[...slug]/_event-handler.tsx

import { checkEventExistsDirect, checkWorkshopExistsDirect } from "@/payload/db";

/**
 * Verifies this slug belongs to the event collection, not workshops
 * Called after route detection passes
 */
export async function verifyIsEvent(
  eventSlug: string,
  tenant: string
): Promise<boolean> {
  const isWorkshop = await checkWorkshopExistsDirect(eventSlug, tenant);
  // If it's a workshop, this handler shouldn't process it
  return !isWorkshop;
}

/**
 * Generates SEO metadata for the event page
 */
export async function generateEventMetadata(
  slug: string[],
  tenant: string,
  draft: boolean = true
): Promise<Metadata | null> {
  if (!isEventRoute(slug)) {
    return null;
  }

  const eventSlug = getEventSlugFromRoute(slug);

  try {
    // Verify this is actually an event, not a workshop
    const isEvent = await verifyIsEvent(eventSlug, tenant);
    if (!isEvent) {
      console.log("[EventPreview] Slug belongs to workshop collection:", {
        eventSlug,
      });
      return null;
    }

    // Now it's safe to fetch and generate metadata
    const event = await getEventBySlug(eventSlug, tenant, { draft, depth: 3 });

    if (!event) {
      return {
        title: "Event Not Found",
        description: "The event you are looking for does not exist.",
      };
    }

    return await generateEventSEOMetadata(event, eventSlug, { tenant });
  } catch (error) {
    console.error(`[EventPreview] Error generating metadata:`, error);
    return null;
  }
}

/**
 * Renders the event preview page
 */
export async function renderEventHandler(
  slug: string[],
  tenant: string,
  draft: boolean = true
) {
  if (!isEventRoute(slug)) {
    return null;
  }

  const eventSlug = getEventSlugFromRoute(slug);

  try {
    // Verify this is actually an event, not a workshop
    const isEvent = await verifyIsEvent(eventSlug, tenant);
    if (!isEvent) {
      console.log("[EventPreview] Slug belongs to workshop collection:", {
        eventSlug,
      });
      return null; // Let workshop handler try
    }

    const event = await getEventBySlug(eventSlug, tenant, { draft, depth: 3 });

    if (!event) {
      return notFound();
    }

    const pathname = `/events/${eventSlug}`;

    return (
      <PageLayout pathname={pathname}>
        <EventTemplate event={event} />
      </PageLayout>
    );
  } catch (error) {
    console.error(`[EventPreview] Error rendering event:`, error);
    return notFound();
  }
}
```

And similarly for workshops:

```typescript
// File: src/app/(frontend)/events/[...slug]/_workshop-handler.tsx

import { checkEventExistsDirect, checkWorkshopExistsDirect } from "@/payload/db";

/**
 * Verifies this slug belongs to the workshop collection, not events
 */
export async function verifyIsWorkshop(
  workshopSlug: string,
  tenant: string
): Promise<boolean> {
  const isEvent = await checkEventExistsDirect(workshopSlug, tenant);
  // If it's an event, this handler shouldn't process it
  return !isEvent;
}

export async function generateWorkshopMetadata(
  slug: string[],
  tenant: string,
  draft: boolean = true
): Promise<Metadata | null> {
  if (!isWorkshopRoute(slug)) {
    return null;
  }

  const workshopSlug = getWorkshopSlugFromRoute(slug);

  try {
    // Verify this is actually a workshop, not an event
    const isWorkshop = await verifyIsWorkshop(workshopSlug, tenant);
    if (!isWorkshop) {
      console.log("[WorkshopPreview] Slug belongs to event collection:", {
        workshopSlug,
      });
      return null;
    }

    const workshop = await getWorkshopBySlug(workshopSlug, tenant, {
      draft,
      depth: 3,
    });

    if (!workshop) {
      return {
        title: "Workshop Not Found",
        description: "The workshop you are looking for does not exist.",
      };
    }

    return await generateWorkshopSEOMetadata(workshop, workshopSlug, {
      tenant,
    });
  } catch (error) {
    console.error(`[WorkshopPreview] Error generating metadata:`, error);
    return null;
  }
}

export async function renderWorkshopHandler(
  slug: string[],
  tenant: string,
  draft: boolean = true
) {
  if (!isWorkshopRoute(slug)) {
    return null;
  }

  const workshopSlug = getWorkshopSlugFromRoute(slug);

  try {
    // Verify this is actually a workshop, not an event
    const isWorkshop = await verifyIsWorkshop(workshopSlug, tenant);
    if (!isWorkshop) {
      console.log("[WorkshopPreview] Slug belongs to event collection:", {
        workshopSlug,
      });
      return null; // Let event handler try
    }

    const workshop = await getWorkshopBySlug(workshopSlug, tenant, {
      draft,
      depth: 3,
    });

    if (!workshop) {
      return notFound();
    }

    const pathname = `/events/${workshopSlug}`;

    return (
      <PageLayout pathname={pathname}>
        <WorkshopTemplate workshop={workshop} />
      </PageLayout>
    );
  } catch (error) {
    console.error(`[WorkshopPreview] Error rendering workshop:`, error);
    return notFound();
  }
}
```

The key pattern here: each handler checks if the slug belongs to the other collection first. If it does, it returns `null` immediately. This is the "counter-check" that ensures handlers don't process content they shouldn't.

---

## Step 4: Orchestrate Handlers in the Main Page Component

The main page component is where the orchestration happens. It calls each handler in sequence, and uses the first one that returns non-null content.

```typescript
// File: src/app/(frontend)/events/[...slug]/page.tsx

import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import React from "react";

import {
  generateEventMetadata as generateEventMeta,
  renderEventHandler as renderEventPage,
} from "./_event-handler";
import {
  generateWorkshopMetadata as generateWorkshopMeta,
  renderWorkshopHandler as renderWorkshopPage,
} from "./_workshop-handler";
import { RefreshRouteOnSave } from "./refresh-route-on-save";

type Props = {
  params: Promise<{ slug?: string[]; tenant: string }>;
};

/**
 * Generate metadata for both event and workshop routes
 * Handlers return null if they don't match, so we try each in sequence
 */
export async function generateMetadata({
  params,
}: Props): Promise<Metadata> {
  const resolvedParams = await params;
  const { slug, tenant } = resolvedParams;
  const { isEnabled: isDraft } = await draftMode();

  if (!slug) return {};

  // Try event handler first
  const eventMetadata = await generateEventMeta(slug, tenant, isDraft);
  if (eventMetadata) return eventMetadata;

  // Try workshop handler second
  const workshopMetadata = await generateWorkshopMeta(slug, tenant, isDraft);
  if (workshopMetadata) return workshopMetadata;

  // Neither handler matched - return default
  return {
    title: "Events",
    description: "Browse our events and workshops",
  };
}

/**
 * Main page component that renders either an event or workshop
 * Handlers return null if they don't match, so we orchestrate through both
 */
export default async function EventsPage({
  params: paramsPromise,
}: Props) {
  const params = await paramsPromise;
  const { slug, tenant } = params;
  const { isEnabled: isDraft } = await draftMode();

  if (!slug) return notFound();

  // Try event handler first
  const eventPage = await renderEventPage(slug, tenant, isDraft);
  if (eventPage) {
    return (
      <React.Fragment>
        {isDraft && <RefreshRouteOnSave tenantSlug={tenant} />}
        {eventPage}
      </React.Fragment>
    );
  }

  // Try workshop handler second
  const workshopPage = await renderWorkshopPage(slug, tenant, isDraft);
  if (workshopPage) {
    return (
      <React.Fragment>
        {isDraft && <RefreshRouteOnSave tenantSlug={tenant} />}
        {workshopPage}
      </React.Fragment>
    );
  }

  // Neither handler matched - 404
  return notFound();
}
```

The orchestration pattern is elegant: call handlers in priority order, each returns `null` if it doesn't handle the request, and use the first non-null result. If all return `null`, the page returns 404.

---

## How It Works in Practice

Let's trace through two requests to understand the flow:

**Request: `/events/morning-yoga`**

1. Main page receives `slug: ['morning-yoga']`, `tenant: 'main'`
2. Calls `generateEventMeta(['morning-yoga'], 'main', false)`
   - `isEventRoute(['morning-yoga'])` → `true` ✓
   - `verifyIsEvent('morning-yoga', 'main')` → checks workshops DB → not found → returns `true` ✓
   - Fetches event, generates metadata → returns metadata object ✓
3. Metadata is set
4. Calls `renderEventPage(['morning-yoga'], 'main', false)`
   - Route detection and verification pass ✓
   - Fetches event data → renders EventTemplate ✓

**Request: `/events/instructor/jane-doe/advanced-python`**

1. Main page receives `slug: ['instructor', 'jane-doe', 'advanced-python']`, `tenant: 'main'`
2. Calls `generateEventMeta(['instructor', 'jane-doe', 'advanced-python'], 'main', false)`
   - `isEventRoute(['instructor', 'jane-doe', 'advanced-python'])` → `false` (not a category/location route) → returns `null` ✗
3. Calls `generateWorkshopMeta(['instructor', 'jane-doe', 'advanced-python'], 'main', false)`
   - `isWorkshopRoute(['instructor', 'jane-doe', 'advanced-python'])` → `true` (matches instructor pattern) ✓
   - `verifyIsWorkshop('advanced-python', 'main')` → checks events DB → not found → returns `true` ✓
   - Fetches workshop, generates metadata → returns metadata object ✓
4. Metadata is set
5. Calls `renderEventPage(...)` → `null` ✗
6. Calls `renderWorkshopPage(...)` → renders WorkshopTemplate ✓

---

## Key Concepts Explained

**Why non-cached verification functions?**
The verification functions (`checkEventExistsDirect`, `checkWorkshopExistsDirect`) skip Next.js `unstable_cache` entirely. This is intentional. These functions might be called:
- During `generateMetadata()` (which runs during build time)
- During route rendering (request time)
- In background jobs or API routes (non-Next.js context)

Using `unstable_cache` in non-Next.js contexts throws errors. By keeping them non-cached, they're safe everywhere. The database hit is minimal anyway—you're only fetching the slug field with a single document limit.

**Why fetch only the slug field?**
In the verification functions, we use `select: { slug: true }` to fetch only the slug. This is a micro-optimization that reduces database load and transfer time. You don't need the full document—you only need to know if it exists.

**Why check the opposing collection first?**
Each handler verifies that the slug doesn't belong to the other collection. This "counter-check" is the disambiguation mechanism. It prevents an event handler from trying to process a workshop slug.

**Why two layers of detection?**
Route detection (synchronous, path-based) is a first pass that's cheap and fast. Verification (database-based) is the authoritative source of truth. Together they're efficient: routes that obviously don't match fail fast, and only ambiguous cases hit the database.

---

## Extending to More Collections

The pattern scales well. If you needed to add a third type (say, "Conferences") to the same `/events` route:

1. Add `checkConferenceExistsDirect()` to your database layer
2. Create `_conference-handler.tsx` with its own route detection and verification
3. In verification functions, add checks for the other two collections:
   ```typescript
   const isEvent = await checkEventExistsDirect(slug, tenant);
   const isWorkshop = await checkWorkshopExistsDirect(slug, tenant);
   return !isEvent && !isWorkshop; // Only if not either of the others
   ```
4. Add the conference handler to the main page component's orchestration
5. The sequence determines priority—handlers tried first get first chance at matching

---

## Conclusion

This pattern solves a real problem that many CMS projects face: business requirements often dictate unified URLs for better SEO and user experience, but data structure differences demand separate collections. Rather than forcing content into unnatural structures or creating awkward URLs, you can embrace both requirements by building smart disambiguation logic.

The key takeaway is this: let route detection be permissive (accept multiple possibilities), let database verification be authoritative (the true arbiter of which collection owns a slug), and let handler orchestration be sequential (first match wins). Together, these three layers create a clean, maintainable system that handles complex real-world scenarios.

You now have a reusable pattern that works for any number of collections on the same path. Whether you're dealing with events and workshops, products and content products, or blog posts and news articles, the approach remains the same: detect permissively, verify authoritatively, orchestrate sequentially.

Let me know in the comments if you have questions about adapting this pattern to your specific needs, and subscribe for more practical development guides.

Thanks, Matija