BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Payload CMS Next.js: Build One Dynamic page.tsx Route

Payload CMS Next.js: Build One Dynamic page.tsx Route

Run editorial pages from app/[locale]/[[...slug]]/page.tsx with Payload blocks, a block renderer, and tenant-aware…

28th July 2026·Updated on:4th August 2026··
Payload
Payload CMS Next.js: Build One Dynamic page.tsx 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.

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

Contents

  • The habit this breaks
  • The Pages collection
  • The block renderer
  • The shared route
  • Fetching the page document
  • Running this across multiple brands
  • Where a dedicated route is the right call
  • Terminology worth keeping straight
  • Common mistakes worth avoiding
  • FAQ
  • Wrapping up
On this page:
  • The habit this breaks
  • The Pages collection
  • The block renderer
  • The shared route
  • Fetching the page document
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

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 shapeRendering approachEditor control
Free-form editorial pages (about, campaigns, landing pages)Shared [[...slug]] route, Pages collection, layout blocks arrayChoose and order blocks freely
Stable structured content (recipes, products, events)Dedicated collection and route, fixed template composing blocksFill 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 resolverFill 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