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.
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:
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
*/exportfunctionisEventRoute(slug: string[]): boolean {
// Direct access: /events/slug (2 segments)if (slug.length === 1) {
returntrue;
}
// Categorized: /events/category/name/slug (4 segments minimum)if (slug.length >= 4 && slug[1] === "category") {
returntrue;
}
// By location: /events/location/name/slug (4 segments minimum)if (slug.length >= 4 && slug[1] === "location") {
returntrue;
}
returnfalse;
}
/**
* Extracts the event slug from various route patterns
* Always the last segment of the URL
*/exportfunctiongetEventSlugFromRoute(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
*/exportfunctionisWorkshopRoute(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 stepif (slug.length === 1) {
returntrue;
}
// By instructor: /events/instructor/name/slug (4 segments)if (slug.length >= 4 && slug[1] === "instructor") {
returntrue;
}
returnfalse;
}
exportfunctiongetWorkshopSlugFromRoute(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.tsximport { checkEventExistsDirect, checkWorkshopExistsDirect } from"@/payload/db";
/**
* Verifies this slug belongs to the event collection, not workshops
* Called after route detection passes
*/exportasyncfunctionverifyIsEvent(eventSlug: string,
tenant: string): Promise<boolean> {
const isWorkshop = awaitcheckWorkshopExistsDirect(eventSlug, tenant);
// If it's a workshop, this handler shouldn't process itreturn !isWorkshop;
}
/**
* Generates SEO metadata for the event page
*/exportasyncfunctiongenerateEventMetadata(slug: string[],
tenant: string,
draft: boolean = true): Promise<Metadata | null> {
if (!isEventRoute(slug)) {
returnnull;
}
const eventSlug = getEventSlugFromRoute(slug);
try {
// Verify this is actually an event, not a workshopconst isEvent = awaitverifyIsEvent(eventSlug, tenant);
if (!isEvent) {
console.log("[EventPreview] Slug belongs to workshop collection:", {
eventSlug,
});
returnnull;
}
// Now it's safe to fetch and generate metadataconst event = awaitgetEventBySlug(eventSlug, tenant, { draft, depth: 3 });
if (!event) {
return {
title: "Event Not Found",
description: "The event you are looking for does not exist.",
};
}
returnawaitgenerateEventSEOMetadata(event, eventSlug, { tenant });
} catch (error) {
console.error(`[EventPreview] Error generating metadata:`, error);
returnnull;
}
}
/**
* Renders the event preview page
*/exportasyncfunctionrenderEventHandler(slug: string[],
tenant: string,
draft: boolean = true) {
if (!isEventRoute(slug)) {
returnnull;
}
const eventSlug = getEventSlugFromRoute(slug);
try {
// Verify this is actually an event, not a workshopconst isEvent = awaitverifyIsEvent(eventSlug, tenant);
if (!isEvent) {
console.log("[EventPreview] Slug belongs to workshop collection:", {
eventSlug,
});
returnnull; // Let workshop handler try
}
const event = awaitgetEventBySlug(eventSlug, tenant, { draft, depth: 3 });
if (!event) {
returnnotFound();
}
const pathname = `/events/${eventSlug}`;
return (
<PageLayoutpathname={pathname}><EventTemplateevent={event} /></PageLayout>
);
} catch (error) {
console.error(`[EventPreview] Error rendering event:`, error);
returnnotFound();
}
}
And similarly for workshops:
typescript
// File: src/app/(frontend)/events/[...slug]/_workshop-handler.tsximport { checkEventExistsDirect, checkWorkshopExistsDirect } from"@/payload/db";
/**
* Verifies this slug belongs to the workshop collection, not events
*/exportasyncfunctionverifyIsWorkshop(workshopSlug: string,
tenant: string): Promise<boolean> {
const isEvent = awaitcheckEventExistsDirect(workshopSlug, tenant);
// If it's an event, this handler shouldn't process itreturn !isEvent;
}
exportasyncfunctiongenerateWorkshopMetadata(slug: string[],
tenant: string,
draft: boolean = true): Promise<Metadata | null> {
if (!isWorkshopRoute(slug)) {
returnnull;
}
const workshopSlug = getWorkshopSlugFromRoute(slug);
try {
// Verify this is actually a workshop, not an eventconst isWorkshop = awaitverifyIsWorkshop(workshopSlug, tenant);
if (!isWorkshop) {
console.log("[WorkshopPreview] Slug belongs to event collection:", {
workshopSlug,
});
returnnull;
}
const workshop = awaitgetWorkshopBySlug(workshopSlug, tenant, {
draft,
depth: 3,
});
if (!workshop) {
return {
title: "Workshop Not Found",
description: "The workshop you are looking for does not exist.",
};
}
returnawaitgenerateWorkshopSEOMetadata(workshop, workshopSlug, {
tenant,
});
} catch (error) {
console.error(`[WorkshopPreview] Error generating metadata:`, error);
returnnull;
}
}
exportasyncfunctionrenderWorkshopHandler(slug: string[],
tenant: string,
draft: boolean = true) {
if (!isWorkshopRoute(slug)) {
returnnull;
}
const workshopSlug = getWorkshopSlugFromRoute(slug);
try {
// Verify this is actually a workshop, not an eventconst isWorkshop = awaitverifyIsWorkshop(workshopSlug, tenant);
if (!isWorkshop) {
console.log("[WorkshopPreview] Slug belongs to event collection:", {
workshopSlug,
});
returnnull; // Let event handler try
}
const workshop = awaitgetWorkshopBySlug(workshopSlug, tenant, {
draft,
depth: 3,
});
if (!workshop) {
returnnotFound();
}
const pathname = `/events/${workshopSlug}`;
return (
<PageLayoutpathname={pathname}><WorkshopTemplateworkshop={workshop} /></PageLayout>
);
} catch (error) {
console.error(`[WorkshopPreview] Error rendering workshop:`, error);
returnnotFound();
}
}
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.tsximport { draftMode } from"next/headers";
import { notFound } from"next/navigation";
importtype { Metadata } from"next";
importReactfrom"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";
typeProps = {
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
*/exportasyncfunctiongenerateMetadata({
params,
}: Props): Promise<Metadata> {
const resolvedParams = await params;
const { slug, tenant } = resolvedParams;
const { isEnabled: isDraft } = awaitdraftMode();
if (!slug) return {};
// Try event handler firstconst eventMetadata = awaitgenerateEventMeta(slug, tenant, isDraft);
if (eventMetadata) return eventMetadata;
// Try workshop handler secondconst workshopMetadata = awaitgenerateWorkshopMeta(slug, tenant, isDraft);
if (workshopMetadata) return workshopMetadata;
// Neither handler matched - return defaultreturn {
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
*/exportdefaultasyncfunctionEventsPage({
params: paramsPromise,
}: Props) {
const params = await paramsPromise;
const { slug, tenant } = params;
const { isEnabled: isDraft } = awaitdraftMode();
if (!slug) returnnotFound();
// Try event handler firstconst eventPage = awaitrenderEventPage(slug, tenant, isDraft);
if (eventPage) {
return (
<React.Fragment>
{isDraft && <RefreshRouteOnSavetenantSlug={tenant} />}
{eventPage}
</React.Fragment>
);
}
// Try workshop handler secondconst workshopPage = awaitrenderWorkshopPage(slug, tenant, isDraft);
if (workshopPage) {
return (
<React.Fragment>
{isDraft && <RefreshRouteOnSavetenantSlug={tenant} />}
{workshopPage}
</React.Fragment>
);
}
// Neither handler matched - 404returnnotFound();
}
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
Main page receives slug: ['morning-yoga'], tenant: 'main'
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:
Add checkConferenceExistsDirect() to your database layer
Create _conference-handler.tsx with its own route detection and verification
In verification functions, add checks for the other two collections:
typescript
const isEvent = awaitcheckEventExistsDirect(slug, tenant);
const isWorkshop = awaitcheckWorkshopExistsDirect(slug, tenant);
return !isEvent && !isWorkshop; // Only if not either of the others
Add the conference handler to the main page component's orchestration
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.