Skip to Content
Delmare Digital

Installation

Install payload-page-tree, add it to your Payload config, prepare your slug field and route pages by their full slug.

Install the package, register the plugin, and give your page collections a plain slug field. The plugin adds everything else.

npm i @delmaredigital/payload-page-tree @payloadcms/db-postgres

This example uses an existing Payload app with its Next.js routes, admin layout and @payload-config alias. Set the Postgres connection and Payload secret:

.env
PAYLOAD_SECRET=replace-with-a-random-secret-at-least-32-characters
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/my-app

Add the plugin

src/payload.config.ts
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { pageTreePlugin } from '@delmaredigital/payload-page-tree'
import { Pages } from './collections/Pages'

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET!,
  db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URL! } }),
  admin: { user: 'users' },
  collections: [{ slug: 'users', auth: true, fields: [] }, Pages],
  plugins: [
    pageTreePlugin(), // Auto-detects 'pages' and 'posts' if they exist
  ],
})

With no options the plugin manages pages and posts, and silently skips whichever of the two your config doesn't define. See Configuration to choose other collections.

Define your collection

Each managed collection must have a text field named slug. The plugin makes it read-only and fills it from the folder path and the page's own segment.

src/collections/Pages/index.ts
import type { CollectionConfig } from 'payload'

export const Pages: CollectionConfig = {
  slug: 'pages',
  versions: { drafts: true },
  fields: [
    { name: 'title', type: 'text', required: true },
    // A plain slug field — the plugin manages it
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      index: true,
    },
    // ... other fields
  ],
}

Don't use slugField()

Use a plain text field, not Payload's slugField() helper. slugField() adds its own hooks, and they conflict with the plugin's slug generation.

Tree creation supplies title, folder, sortOrder, the configured page segment field (default pageSegment) and _status: 'draft', with draft: true. Enable versions: { drafts: true } as above so Payload can save incomplete required content fields. Drafts are also needed for the tree's Publish and Unpublish actions. Without drafts enabled, creation can fail required-field validation and status changes have no publishing effect.

Route pages by slug

Add a catch-all route that looks up the page by its full slug:

app/[...slug]/page.tsx
import { getPayload } from 'payload'
import { notFound } from 'next/navigation'
import config from '@payload-config'

export default async function Page({ params }: { params: Promise<{ slug: string[] }> }) {
  const { slug } = await params
  const fullSlug = slug.join('/')

  const payload = await getPayload({ config })
  const { docs } = await payload.find({
    collection: 'pages',
    where: { and: [
      { slug: { equals: fullSlug } },
      { _status: { equals: 'published' } },
    ] },
    limit: 1,
  })

  const page = docs[0]
  if (!page) notFound()
  return <h1>{page.title}</h1>
}

Slugs are stored without a leading slash (appeals/2024/spring-campaign), so joining the route segments with / matches them directly. On Next.js 14, params is a plain object rather than a promise.

That's it

The plugin adds folder, pageSegment, sortOrder and slugHistory fields to your collection and registers the tree view at /admin/page-tree.

What the plugin adds

On each managed collection:

FieldTypePurpose
folderrelationshipAdded by Payload's folders feature, which the plugin turns on (folders: true).
pageSegmenttext, sidebarThe page's own URL segment. Slugified on save; generated from title on create when left empty.
sortOrdernumber, hiddenPosition within its folder in the tree. Defaults to 0.
slugHistoryarray, read-only, sidebarPrevious URLs, at most 20 rows. See URL history.

On the folders collection (payload-folders by default):

FieldTypePurpose
pathSegmenttext, sidebarThe folder's URL segment. Slugified on save; generated from the folder name when left empty.
sortOrdernumber, hiddenPosition within its parent in the tree. Defaults to 0.

The existing slug field becomes read-only, with the description "Auto-generated from folder path + page segment" (or "Auto-generated by custom buildSlug function" when you set buildSlug).

Edit this page on GitHub

On this page