---
title: "Payload CMS Next.js: Build One Dynamic page.tsx Route"
slug: "payload-nextjs-single-dynamic-page"
published: "2026-07-28"
updated: "2026-08-04"
validated: "2026-08-04"
categories:
  - "Payload"
tags:
  - "Payload CMS Next.js"
  - "Payload CMS"
  - "Next.js App Router"
  - "catch-all route"
  - "page.tsx dynamic route"
  - "block renderer"
  - "multi-tenant Payload"
  - "tenant-aware queries"
  - "Payload blocks"
  - "structured content templates"
  - "dynamic routing Next.js"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "next.js (app router)"
  - "react"
  - "typescript"
  - "payload multi-tenant plugin"
status: "stable"
llm-purpose: "Payload CMS Next.js guide: use one dynamic app/[locale]/[[...slug]]/page.tsx with block renderers and tenant-aware queries to build multi-brand editorial…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to Next.js (App Router)"
  - "Access to React"
  - "Access to TypeScript"
  - "Access to Payload multi-tenant plugin"
llm-outputs:
  - "Completed outcome: Payload CMS Next.js guide: use one dynamic app/[locale]/[[...slug]]/page.tsx with block renderers and tenant-aware queries to build multi-brand editorial…"
---

**Summary Triples**
- (route, path, app/[locale]/[[...slug]]/page.tsx)
- (route, resolves, tenant, locale, and slug into a single Payload document)
- (document.layout, isRenderedBy, a block renderer that maps block.type to React components)
- (presentation, livesIn, the blocks attached to the document's layout array (no per-page template files))
- (multi-tenant, requires, tenant-aware queries and theme/navigation scoping)
- (blockRenderer, shouldProvide, type-to-component mapping, props typing, default fallback, and SSR/streaming support)
- (preferredUseCase, is, many editorial pages and microsites sharing approved blocks without per-page templates)
- (exceptions, useDedicatedRouteWhen, page needs custom server-side logic, special dynamic params, or bespoke performance optimizations)
- (next-app-router, recommendation, use async fetch in page.tsx and choose static/ISR/dynamic caching intentionally (generateStaticParams or dynamic flag))
- (editorWorkflow, benefit, editors compose pages using approved blocks without code changes or PRs)

### {GOAL}
Payload CMS Next.js guide: use one dynamic app/[locale]/[[...slug]]/page.tsx with block renderers and tenant-aware queries to build multi-brand editorial…

### {PREREQS}
- Access to Payload CMS
- Access to Next.js (App Router)
- Access to React
- Access to TypeScript
- Access to Payload multi-tenant plugin

### {STEPS}
1. Design the Pages collection
2. Create block configs
3. Build a block renderer
4. Implement the catch-all route
5. Query pages with tenant awareness
6. Add site theming
7. Decide when to use templates

<!-- llm:goal="Payload CMS Next.js guide: use one dynamic app/[locale]/[[...slug]]/page.tsx with block renderers and tenant-aware queries to build multi-brand editorial…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Next.js (App Router)" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Payload multi-tenant plugin" -->
<!-- llm:output="Completed outcome: Payload CMS Next.js guide: use one dynamic app/[locale]/[[...slug]]/page.tsx with block renderers and tenant-aware queries to build multi-brand editorial…" -->

# Payload CMS Next.js: Build One Dynamic page.tsx Route
> Payload CMS Next.js guide: use one dynamic app/[locale]/[[...slug]]/page.tsx with block renderers and tenant-aware queries to build multi-brand editorial…
Matija Žiberna · 2026-07-28

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:

```ts
// 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,
      ],
    },
  ],
}
```

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.

A Hero block definition looks like this:

```ts
// 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,
        },
      ],
    },
  ],
}
```

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.

```tsx
// 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 blockRenderers = {
  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 (!blocks?.length) {
    return null
  }

  return blocks.map((block, index) => {
    const Renderer =
      blockRenderers[block.blockType as keyof typeof blockRenderers]

    if (!Renderer) {
      console.warn(`No renderer registered for block: ${block.blockType}`)
      return null
    }

    return (
      <Renderer
        key={block.id ?? `${block.blockType}-${index}`}
        {...block}
        site={site}
      />
    )
  })
}
```

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.

```text
app/
  (frontend)/
    [locale]/
      [[...slug]]/
        page.tsx
```

```tsx
// File: app/(frontend)/[locale]/[[...slug]]/page.tsx
import { notFound } from 'next/navigation'

import { RenderBlocks } from '@/blocks/RenderBlocks'
import { getPage } from '@/data/getPage'
import { resolveSiteFromRequest } from '@/sites/resolveSiteFromRequest'
import { SiteThemeProvider } from '@/sites/SiteThemeProvider'

type PageProps = {
  params: Promise<{
    locale: string
    slug?: string[]
  }>
}

export default async function Page({ params }: PageProps) {
  const { locale, slug } = await params

  const site = await resolveSiteFromRequest()

  const path = slug?.join('/') ?? 'home'

  const page = await getPage({
    siteID: site.id,
    locale,
    path,
  })

  if (!page) {
    notFound()
  }

  return (
    <SiteThemeProvider site={site}>
      <RenderBlocks blocks={page.layout} site={site} />
    </SiteThemeProvider>
  )
}
```

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:

```ts
// File: src/data/getPage.ts
import { getPayload } from 'payload'

import config from '@payload-config'

type GetPageArgs = {
  siteID: string
  locale: string
  path: string
}

export async function getPage({ siteID, locale, path }: GetPageArgs) {
  const payload = await getPayload({ config })

  const result = await payload.find({
    collection: 'pages',
    locale,
    fallbackLocale: false,
    draft: false,
    limit: 1,
    depth: 2,
    where: {
      and: [
        {
          tenant: {
            equals: siteID,
          },
        },
        {
          slug: {
            equals: path,
          },
        },
      ],
    },
  })

  return result.docs[0] ?? null
}
```

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`, `cytomatrix.ca`, `orangenaturals.com`. A request to `https://canprev.ca/en/about` resolves to tenant `canprev`, locale `en`, path `about`. A request to `https://cytomatrix.ca/en/about` resolves to tenant `cytomatrix`, same locale, same path. The route handling both requests is the same file. What differs is the document `getPage` returns, and the theme `SiteThemeProvider` 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
// File: src/sites/SiteThemeProvider.tsx
export function SiteThemeProvider({
  site,
  children,
}: {
  site: Site
  children: React.ReactNode
}) {
  return (
    <div
      data-site={site.slug}
      style={
        {
          '--brand-primary': site.theme.primaryColor,
          '--brand-secondary': site.theme.secondaryColor,
          '--font-heading': site.theme.headingFont,
          '--radius-card': site.theme.cardRadius,
        } as React.CSSProperties
      }
    >
      {children}
    </div>
  )
}
```

A shared Hero component reads those tokens directly:

```tsx
// File: src/blocks/Hero/Component.tsx
export function HeroBlock({
  heading,
  description,
  image,
  actions,
}: HeroBlockData) {
  return (
    <section className="bg-[var(--brand-primary)]">
      <div className="site-container">
        <h1 className="font-[var(--font-heading)]">{heading}</h1>
        {description && <p>{description}</p>}
        {/* Image and actions */}
      </div>
    </section>
  )
}
```

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:

```tsx
// File: src/blocks/Hero/Component.tsx
const heroPresentations = {
  canprev: CanPrevHeroPresentation,
  cytomatrix: CytomatrixHeroPresentation,
  'orange-naturals': OrangeNaturalsHeroPresentation,
}

export function HeroBlock({
  site,
  ...block
}: HeroBlockData & { site: Site }) {
  const Presentation =
    heroPresentations[site.slug as keyof typeof heroPresentations] ??
    DefaultHeroPresentation

  return <Presentation {...block} />
}
```

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:

```tsx
// File: app/(frontend)/[locale]/recipes/[slug]/page.tsx
import { notFound } from 'next/navigation'

import { RecipeTemplate } from '@/templates/Recipe'
import { getRecipe } from '@/data/getRecipe'
import { resolveSiteFromRequest } from '@/sites/resolveSiteFromRequest'

type RecipePageProps = {
  params: Promise<{
    locale: string
    slug: string
  }>
}

export default async function RecipePage({ params }: RecipePageProps) {
  const { locale, slug } = await params

  const site = await resolveSiteFromRequest()

  const recipe = await getRecipe({
    siteID: site.id,
    locale,
    slug,
  })

  if (!recipe) {
    notFound()
  }

  return <RecipeTemplate recipe={recipe} site={site} />
}
```

`RecipeTemplate` composes existing blocks into a fixed order rather than reading an editor-defined `layout` array:

```tsx
// File: src/templates/Recipe.tsx
import { RecipeHeroBlock } from '@/blocks/RecipeHero/Component'
import { RecipeMetaBlock } from '@/blocks/RecipeMeta/Component'
import { RecipeIngredientsBlock } from '@/blocks/RecipeIngredients/Component'
import { RecipeInstructionsBlock } from '@/blocks/RecipeInstructions/Component'
import { RelatedProductsBlock } from '@/blocks/RelatedProducts/Component'
import { FAQBlock } from '@/blocks/FAQ/Component'

export function RecipeTemplate({
  recipe,
  site,
}: {
  recipe: Recipe
  site: Site
}) {
  return (
    <>
      <RecipeHeroBlock
        title={recipe.title}
        description={recipe.description}
        image={recipe.image}
        site={site}
      />
      <RecipeMetaBlock
        preparationTime={recipe.preparationTime}
        cookingTime={recipe.cookingTime}
        servings={recipe.servings}
        site={site}
      />
      <RecipeIngredientsBlock ingredients={recipe.ingredients} site={site} />
      <RecipeInstructionsBlock instructions={recipe.instructions} site={site} />
      <RelatedProductsBlock products={recipe.relatedProducts} site={site} />
      {recipe.faq?.length > 0 && <FAQBlock items={recipe.faq} site={site} />}
    </>
  )
}
```

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.

| Content shape | Rendering approach | Editor control |
|---|---|---|
| Free-form editorial pages (about, campaigns, landing pages) | Shared `[[...slug]]` route, Pages collection, `layout` blocks array | Choose and order blocks freely |
| Stable structured content (recipes, products, events) | Dedicated collection and route, fixed template composing blocks | Fill in structured fields; layout stays fixed |
| Content needing specialized data joins (a product route merging Payload content with PIM, inventory, and reviews) | Dedicated route with a custom resolver | Fill in structured fields; composition logic lives in the resolver |

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

## LLM Response Snippet
```json
{
  "goal": "Payload CMS Next.js guide: use one dynamic app/[locale]/[[...slug]]/page.tsx with block renderers and tenant-aware queries to build multi-brand editorial…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS Next.js: Build One Dynamic page.tsx Route\" cover?",
      "answer": "Payload CMS Next.js guide: use one dynamic app/[locale]/[[...slug]]/page.tsx with block renderers and tenant-aware queries to build multi-brand editorial…"
    }
  ]
}
```