BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Serve Multiple Payload CMS Collections from One Route

Serve Multiple Payload CMS Collections from One Route

Disambiguate slugs in Next.js with Payload CMS using route detection, direct verification, and handler orchestration…

18th December 2025·Updated on:19th July 2026··
Payload
Serve Multiple Payload CMS Collections from One Route

Evaluating Payload CMS Implementation Costs?

Scope design, content structure, and migration hours to estimate a realistic production timeline and hosting setup.

Try the Cost EstimatorGet a Second Opinion

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

Related Posts:

  • •Active Tenant State Management & Admin Display in Payload CMS
  • •Production-Ready Multi-Tenant Setup with Next.js & Payload
📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

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

Active Tenant State Management & Admin Display in Payload CMS
Active Tenant State Management & Admin Display in Payload CMS

25th September 2025

Production-Ready Multi-Tenant Setup with Next.js & Payload
Production-Ready Multi-Tenant Setup with Next.js & Payload

12th December 2025

Contents

  • Introduction
  • The Problem: Same Path, Different Collections
  • Architecture Overview
  • Step 1: Create Collection Verification Functions
  • Step 2: Create Route Detection Functions
  • Step 3: Create Verification Functions in Handlers
  • Step 4: Orchestrate Handlers in the Main Page Component
  • How It Works in Practice
  • Key Concepts Explained
  • Extending to More Collections
  • Conclusion
On this page:
  • Introduction
  • The Problem: Same Path, Different Collections
  • Architecture Overview
  • Step 1: Create Collection Verification Functions
  • Step 2: Create Route Detection Functions
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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 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:

code
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