A Payload CMS site rendered through Next.js can serve most of its editorial pages — home, about, contact, services, campaigns, microsites, nested marketing pages — from one route: app/[locale]/[[...slug]]/page.tsx. The route resolves a tenant, locale, and slug into a single Payload document, and a block renderer turns that document's stored layout array into the page. Presentation lives entirely in the blocks attached to the document, so a growing site doesn't need a new template file every time an editor wants a new page shape. Below I'll walk through the Pages collection, the block renderer, the catch-all route, and how this holds up across a multi-brand platform — plus where you still want a dedicated route instead.
I've built this pattern most recently on a multi-brand rebuild covering three separate consumer sites sharing one Payload instance. Each brand needed its own theme, navigation, and content, while editors across all three needed the same set of approved blocks to build pages without opening a pull request. That constraint is what pushed the architecture toward one shared route with tenant-aware queries, rather than a route per brand or a template per page type.
The habit this breaks
Developers arriving from WordPress, Drupal, or Craft CMS usually look for a template file per page type: page-about.php, page-contact.php, single-product.php. Developers already comfortable with Next.js often reach for the same instinct in App Router form: a folder per page, app/about/page.tsx, app/contact/page.tsx, app/services/page.tsx.
That instinct comes from CMS platforms where the CMS itself participates in template selection. A page record carries a template value — Default Page, Landing Page, Campaign Page — and the CMS picks a matching file out of the active theme. The rendering path runs from URL to router to page record to selected template to rendered HTML, with the CMS deciding which theme file executes.
Payload works from a different starting point. It's a code-first CMS installed directly into your Next.js application, and it stores structured content rather than owning a template layer. Routing and rendering stay in your Next.js code; Payload's job is to hand that code the data and configuration an editor set up.
The Pages collection
Here's a simplified version of the collection that backs the shared route:
The field that matters here is layout. It's a blocks field, which means each page document stores an ordered array of block entries, and editors choose which blocks appear and in what order. Payload tags each entry with a blockType value matching the block's slug, which the frontend later uses to pick a renderer.
This config is the contract an editor sees in the admin UI: heading, description, an image upload, and up to two actions. Every field here becomes a piece of data the frontend can rely on being present in a predictable shape. The React implementation behind this contract is a separate concern, which is what the renderer below handles.
The block renderer
A page document's layout array needs one thing to turn into HTML: a lookup from blockType to a React component. That lookup is the entire job of the renderer.
Add a new block type in the future, and this is the one file you touch to register it. The route calling RenderBlocks never needs an update, because it only ever passes through whatever layout array the page document contains.
The shared route
With the collection and renderer in place, the route itself stays small. Next.js App Router's optional catch-all segment, [[...slug]], matches the site root along with any number of nested path segments, which is exactly the shape a flexible Pages collection needs.
Note the await params — current App Router versions deliver dynamic route parameters asynchronously, so that await is required, not optional. Beyond that, the route's job is five lookups: resolve the tenant, read the locale, join the slug segments into a path, fetch the matching document, and hand its blocks to the renderer. There's no branching on which page this is. The document coming back from getPage already carries everything the renderer needs.
Fetching the page document
getPage runs on Payload's Local API, which supports collection queries, locale and fallback-locale handling, relationship depth, and access control in a single call:
For deeper page trees you'll usually want a dedicated path field instead of a bare slug — something that can hold values like about/our-story or campaigns/summer/wellness-guide — plus draft preview support, redirects, and caching on top of this base query. The lookup itself always reduces to the same equation: tenant plus locale plus path resolves to one page document.
Running this across multiple brands
This pattern earns its keep on a multi-brand platform. Take three sites sharing one Payload instance: canprev.ca, , . A request to resolves to tenant , locale , path . A request to resolves to tenant , same locale, same path. The route handling both requests is the same file. What differs is the document returns, and the theme applies around it.
Payload's official multi-tenant plugin handles the tenant relationship side of this: it adds tenant fields to your configured collections and scopes both frontend queries and admin-panel visibility by tenant.
All three brands can draw from the same block library — Hero, Text and Media, Cards, Call to Action, FAQ, and so on — because a block's data contract stays constant across tenants even when its visual output changes. A HeroBlockData shape of heading, description, image, and actions works identically whether the brand wants rounded imagery and green accents or clinical typography and blue accents. Most of that variation lives in CSS variables and design tokens rather than in separate block definitions:
tsx
() {
(
)
}
A shared Hero component reads those tokens directly:
tsx
() {
(
)
}
When a brand's visual requirements go past what tokens can express, the block implementation can route to a tenant-specific presentation while keeping one Payload block definition:
Editors across every tenant still configure the same hero block in the admin panel. The tenant-specific React path is an internal detail of that one block's implementation.
Where a dedicated route is the right call
A shared Pages route handles free-form editorial content well, and it's a poor fit for content with a fixed, predictable structure. Products, recipes, events, and giveaways are the common examples — each has a stable set of fields an editor fills in, rather than a stack of blocks they arrange freely.
A recipe typically has title, description, prep time, cook time, servings, ingredients, instructions, dietary tags, related products, an author, and a featured image — a shape that stays constant across every recipe on the site. That belongs in its own collection with its own route:
RecipeTemplate composes existing blocks into a fixed order rather than reading an editor-defined layout array:
tsx
{ }
{ }
{ }
{ }
{ }
{ }
() {
(
)
}
The building blocks here — RecipeHero, RecipeMeta, RecipeIngredients — are the same kind of reusable presentation units as the Pages collection's blocks. The difference is who controls their order: an editor arranges blocks freely on a Pages document, while a template fixes the arrangement in code for a structured content type.
Terminology worth keeping straight
Three words get used loosely on projects like this, and mixing them up causes real confusion between design and engineering:
Block — presentation logic exposed through Payload's admin UI. It defines what fields an editor configures and what data shape the frontend receives. Hero, FAQ, Cards, and CTA are blocks.
Component — an implementation detail inside the frontend codebase. Button, Container, Heading, and Modal are components that a block's React implementation might use internally, without ever being exposed to an editor directly.
Template — a code-defined composition of blocks for a structured content type. RecipeTemplate is a template; it decides the fixed order recipe blocks appear in.
Keeping these separate is what keeps the block library from sprawling: components support blocks, blocks provide the presentation editors can configure, templates compose blocks for fixed content types, and routes decide which of those paths a given request takes.
Common mistakes worth avoiding
A collection per visual variation. Landing Pages, Campaign Pages, and Standard Pages collections often end up storing near-identical block structures. Check whether these are genuinely different content types before splitting them apart.
A Payload block for every component. Buttons, containers, and layout wrappers don't need to be editor-configurable. Expose the presentation choices that matter and keep the rest as implementation.
Tenant checks scattered through routes. Resolve the tenant once, in resolveSiteFromRequest, and pass the result through a consistent site object rather than checking hostname conditionally across the codebase.
Structured content that becomes too freeform. A recipe should keep behaving like a recipe. Blocks are meant to add configurable flexibility within a content type's model, not replace that model entirely.
FAQ
Does every page on a Payload site need to go through the same route?
No. The shared [[...slug]] route is for free-form editorial content. Structured content types — products, recipes, events — get their own collections and routes with fixed templates.
How does the frontend know which component to render for a given block?
Each block entry in a page's layout array carries a blockType value matching the block's slug in its Payload config. The renderer looks up that value in a blockType-to-component map and renders the match.
Can different tenants use different versions of the same block?
Yes. The Payload block definition and its data contract stay shared, while the block's React implementation can branch internally by tenant slug to render a different presentation.
What happens if an editor adds a block type the frontend hasn't implemented yet?
The renderer's lookup returns nothing for an unregistered blockType, logs a warning, and skips that block rather than crashing the page. Registering the new block in blockRenderers is what makes it render.
Does this replace the need for SEO metadata handling per page?
No — metadata generation still needs its own logic per route (or per document, for the shared route), typically pulled from SEO fields stored on the Payload document alongside the layout array.
Wrapping up
The shift this pattern asks for is where presentation logic lives. Payload's layout blocks array on a page document carries the editor's chosen structure; a block renderer maps each entry to a React component; a single catch-all route resolves tenant, locale, and path into that document. Structured content types keep their own collections and fixed templates alongside this shared route, rather than being forced through it. Once that split is in place, adding a new editorial page becomes a content operation instead of a pull request, and a new tenant reuses the same routing and rendering code with its own theme and content on top.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks,
Matija
// File: src/collections/Pages/config.ts
import
type
CollectionConfig
from
'payload'
import
HeroBlock
from
'@/blocks/Hero/config'
import
TextMediaBlock
from
'@/blocks/TextMedia/config'
import
CardsBlock
from
'@/blocks/Cards/config'
import
CallToActionBlock
from
'@/blocks/CallToAction/config'
import
FAQBlock
from
'@/blocks/FAQ/config'
export
const
Pages
CollectionConfig
slug
'pages'
versions
drafts
true
admin
useAsTitle
'title'
fields
name
'title'
type
'text'
required
true
localized
true
name
'slug'
type
'text'
required
true
index
true
name
'layout'
type
'blocks'
localized
true
blocks
HeroBlock
TextMediaBlock
CardsBlock
CallToActionBlock
FAQBlock
// File: src/blocks/Hero/config.ts
import
type
Block
from
'payload'
export
const
HeroBlock
Block
slug
'hero'
interfaceName
'HeroBlock'
fields
name
'heading'
type
'text'
required
true
localized
true
name
'description'
type
'textarea'
localized
true
name
'image'
type
'upload'
relationTo
'media'
name
'actions'
type
'array'
maxRows
2
fields
name
'label'
type
'text'
required
true
localized
true
name
'href'
type
'text'
required
true
// File: src/blocks/RenderBlocks.tsx
import
HeroBlock
from
'@/blocks/Hero/Component'
import
TextMediaBlock
from
'@/blocks/TextMedia/Component'
import
CardsBlock
from
'@/blocks/Cards/Component'
import
CallToActionBlock
from
'@/blocks/CallToAction/Component'
import
FAQBlock
from
'@/blocks/FAQ/Component'
const
hero
HeroBlock
textMedia
TextMediaBlock
cards
CardsBlock
callToAction
CallToActionBlock
faq
FAQBlock
type
RenderBlocksProps
blocks
Array
id
string
null
blockType
string
key
string
unknown
site
id
string
slug
string
export
function
RenderBlocks
{ blocks, site }: RenderBlocksProps
if
length
return
null
return
map
(block, index) =>
const
Renderer
blockType
as
typeof
if
Renderer
console
warn
`No renderer registered for block: ${block.blockType}`