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-postgresThis example uses an existing Payload app with its Next.js routes, admin layout and @payload-config alias. Set the Postgres connection and Payload secret:
PAYLOAD_SECRET=replace-with-a-random-secret-at-least-32-characters
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/my-appAdd the plugin
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.
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:
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:
| Field | Type | Purpose |
|---|---|---|
folder | relationship | Added by Payload's folders feature, which the plugin turns on (folders: true). |
pageSegment | text, sidebar | The page's own URL segment. Slugified on save; generated from title on create when left empty. |
sortOrder | number, hidden | Position within its folder in the tree. Defaults to 0. |
slugHistory | array, read-only, sidebar | Previous URLs, at most 20 rows. See URL history. |
On the folders collection (payload-folders by default):
| Field | Type | Purpose |
|---|---|---|
pathSegment | text, sidebar | The folder's URL segment. Slugified on save; generated from the folder name when left empty. |
sortOrder | number, hidden | Position 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).

