Page Tree for Payload CMS
Extend Payload's built-in folders to auto-generate hierarchical URL slugs. Drag-and-drop tree view, cascading URL updates, and URL history tracking.
Features
- Minimal config — works with
pagesandpostscollections by default (auto-detects what exists) - Visual tree view — drag-and-drop reorganization in admin panel
- Auto-generated slugs — hierarchical URLs from folder paths (e.g.,
/appeals/2024/spring-campaign) - URL history tracking — automatic audit trail of previous URLs with restore capability
- URL preservation — choose to keep or update URLs when moving content
- Collection switching — dropdown to filter by collection type (when multiple configured)
- Native folders — extends Payload's built-in folders feature
- No dummy pages — folders are purely organizational
- Plugin friendly — works alongside Puck, SEO, and other plugins (see Plugin Order)
Installation
pnpm add @delmaredigital/payload-page-tree
Quick Start
Add the Plugin
ts// src/payload.config.ts
import { buildConfig } from 'payload'
import { pageTreePlugin } from '@delmaredigital/payload-page-tree'
import { Pages } from './collections/Pages'
export default buildConfig({
collections: [Pages],
plugins: [
pageTreePlugin(), // Auto-detects 'pages' and 'posts' if they exist
],
})
Define Your Collection
Your collections must have a slug field. The plugin will make it read-only and auto-generate values from folder path + page segment.
ts// src/collections/Pages/index.ts
import type { CollectionConfig } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{ name: 'title', type: 'text', required: true },
// Define a plain slug field — the plugin will manage it
{
name: 'slug',
type: 'text',
required: true,
unique: true,
index: true,
},
// ... other fields
],
}
Important: Do NOT use Payload's slugField() helper function. Use a plain text field instead. The slugField() helper adds its own hooks that conflict with the plugin's auto-generation.
Note: Pages created from the tree view are created as drafts with minimal fields (title, folder, sortOrder). Required field validation is skipped for drafts, so pages with required content fields will be created successfully and can be completed when editing.
Frontend Routing
Set up a catch-all route that queries by the full slug:
ts// app/[...slug]/page.tsx
export default async function Page({ params }: { params: { slug: string[] } }) {
const fullSlug = params.slug.join('/')
const { docs } = await payload.find({
collection: 'pages',
where: { slug: { equals: fullSlug } },
limit: 1,
})
// ...
}
That's it! The plugin automatically adds folder, pageSegment, sortOrder, and slugHistory fields to your collection, and registers the tree view at /admin/page-tree.
How It Works
- Create folders in the Payload admin with a
pathSegment(e.g., "appeals", "2024") - Nest folders to create hierarchy (e.g., "2024" under "appeals")
- Create a page and select a folder from the dropdown
- Slug auto-generates from folder path + page segment (e.g.,
appeals/2024/spring-campaign)
textFolders:
├── appeals (pathSegment: "appeals")
│ ├── 2024 (pathSegment: "2024")
│ └── 2025 (pathSegment: "2025")
└── services (pathSegment: "services")
Page in "2024" folder with pageSegment "spring-campaign":
→ slug: appeals/2024/spring-campaign
→ URL: /appeals/2024/spring-campaign
Tree View
The plugin adds a visual tree view at /admin/page-tree for managing your page hierarchy:
- Drag-and-drop to reorganize pages and folders (supports multi-select)
- Multi-select support — Cmd/Ctrl+click to select multiple items, then drag or use "Move to..."
- "Move to..." action — right-click menu option to select destination folder without dragging
- Sorting options — sort by name, slug, or status (drag-drop disabled while sorting)
- Collection dropdown — switch between page types (only appears when multiple collections are configured)
- Context menu (right-click) for actions like edit, duplicate, publish/unpublish, move, delete
- URL preservation — when moving folders, choose to keep existing URLs or update them
- Bulk URL updates — "Update All" / "Keep All" buttons for batch confirmation
- Regenerate URLs — manually regenerate slugs for a folder and all its contents
- Visual distinction — folders have subtle background styling to differentiate from pages
Cascading Updates
When you rename a folder's pathSegment or move a folder:
- Keep existing URLs — pages stay at their current URLs (default for moves)
- Update URLs — all pages in that folder (and subfolders) get new slugs based on the new path
This gives you control over URL changes — useful when you want to reorganize content without breaking existing links.
URL History & Redirects
The plugin automatically tracks URL changes for SEO and redirect management.
Automatic History Tracking
Every page has a slugHistory field that records the last 20 URL changes:
json{
"slug": "marketing/about-us",
"slugHistory": [
{ "slug": "about-us", "changedAt": "2024-01-15T...", "reason": "move" },
{ "slug": "company/about", "changedAt": "2024-06-01T...", "reason": "rename" }
]
}
Changes are tracked automatically when:
- A page is moved to a different folder (reason:
move) - A parent folder is renamed (reason:
rename) - URLs are regenerated via context menu (reason:
regenerate) - A previous URL is restored (reason:
restore)
Generating Redirects
Use the redirects endpoint to build redirect maps for your frontend:
ts// Fetch all redirect mappings
const res = await fetch('/api/page-tree/redirects?collection=pages')
const { redirects } = await res.json()
// Returns:
// {
// "redirects": [
// { "from": "/about-us", "to": "/marketing/about-us" },
// { "from": "/company/about", "to": "/marketing/about-us" }
// ]
// }
Next.js middleware example
ts// middleware.ts
import { NextResponse } from 'next/server'
export async function middleware(request: NextRequest) {
const res = await fetch(
`${request.nextUrl.origin}/api/page-tree/redirects?collection=pages`
)
const { redirects } = await res.json()
const redirect = redirects.find(
r => r.from === request.nextUrl.pathname
)
if (redirect) {
return NextResponse.redirect(
new URL(redirect.to, request.url), 301
)
}
}
Restoring Previous URLs
Right-click a page in the tree view and select "URL History" to view all previous URLs with timestamps and reasons, or restore any previous URL.
Configuration Options
tspageTreePlugin({
// Collections to add folder-based slugs to (default: ['pages', 'posts'])
collections: ['pages', 'posts'],
// Custom folder collection slug (default: 'payload-folders')
folderSlug: 'payload-folders',
// Field name for folder path segment (default: 'pathSegment')
segmentFieldName: 'pathSegment',
// Field name for page segment (default: 'pageSegment')
pageSegmentFieldName: 'pageSegment',
// Disable plugin hooks while preserving schema (default: false)
disabled: false,
// Admin view configuration
adminView: {
enabled: true, // Show tree view in admin (default: true)
path: '/page-tree', // URL path for tree view (default: '/page-tree')
},
// Custom slug builder (replaces default folderPath/pageSegment)
buildSlug: ({ pageSegment }) => pageSegment,
// Customize the folders collection (add access control, fields, etc.)
customizeFolderCollection: (collection) => collection,
})
| Option | Type | Default | Description |
|---|---|---|---|
collections | string[] | ['pages', 'posts'] | Collections to enable (auto-filters to existing) |
folderSlug | string | 'payload-folders' | Folder collection slug |
segmentFieldName | string | 'pathSegment' | Field name on folders |
pageSegmentFieldName | string | 'pageSegment' | Field name on pages |
disabled | boolean | false | Disable hooks while keeping schema |
adminView.enabled | boolean | true | Show tree view in admin |
adminView.path | string | '/page-tree' | URL path for tree view |
buildSlug | function | — | Custom slug builder (details) |
customizeFolderCollection | function | — | Customize the folders collection |
Custom collections example
The plugin defaults to ['pages', 'posts'] but automatically filters to only collections that exist in your config. If you only have a pages collection, posts is silently ignored.
tspageTreePlugin({
collections: ['pages', 'articles', 'landing-pages'],
})
Custom Slug Generation
By default, slugs are built as folderPath/pageSegment (e.g., appeals/2024/spring/my-page). The buildSlug callback lets you replace this with any custom logic.
Use Cases
- Flat slugs — Use the tree for content organization without affecting URLs
- Random IDs — URLs that never change regardless of tree structure
- Custom patterns — Collection-aware or locale-based slug schemes
Callback Signature
tstype BuildSlugFn = (args: {
folderPath: string | null // e.g., "appeals/2024" or null
pageSegment: string // The page's own URL segment
doc: Record<string, unknown> // The document data being saved
operation: 'create' | 'update' // Current operation type
}) => string | Promise<string>
Examples
Flat slugs (tree is organizational only)
Ignore the folder hierarchy entirely. URLs are just the page segment, so reorganizing the tree never changes URLs.
tspageTreePlugin({
buildSlug: ({ pageSegment }) => pageSegment,
})
A page with segment my-page in folder appeals/2024 gets slug my-page instead of appeals/2024/my-page.
Random IDs
Use a custom ID field or generate random slugs. URLs never change regardless of tree structure or page renaming.
tsimport { nanoid } from 'nanoid'
pageTreePlugin({
buildSlug: ({ doc, operation }) => {
// Keep existing slug on update, generate new one on create
if (operation === 'update' && doc.slug) return doc.slug as string
return nanoid(21)
},
})
Shallow hierarchy (one level only)
Use only the immediate parent folder, not the full path. Limits URL depth regardless of tree nesting.
tspageTreePlugin({
buildSlug: ({ folderPath, pageSegment }) => {
if (!folderPath) return pageSegment
// Take only the last folder segment
const lastFolder = folderPath.split('/').pop()
return `${lastFolder}/${pageSegment}`
},
})
Error handling: If your buildSlug function throws an error or returns a falsy value, the plugin automatically falls back to the default folderPath/pageSegment behavior and logs a warning.
Fully additive: This option is completely optional. Existing configurations work identically without any changes. URL history tracking, cascading updates, and redirects all continue to work with custom slugs.
Plugin Order
Critical: If using with @delmaredigital/payload-puck, plugin order matters. The page-tree plugin must run AFTER any plugin that creates collections it needs to manage.
Why This Matters
The page-tree plugin validates configured collections at initialization. If a collection doesn't exist yet (because another plugin creates it later), page-tree will silently skip it.
ts// CORRECT: Puck creates Pages before page-tree runs
export const plugins = [
createPuckPlugin({ pagesCollection: 'pages' }), // Creates Pages collection
pageTreePlugin({ collections: ['pages'] }), // Now sees Pages
]
// WRONG: page-tree runs before Pages exists
export const plugins = [
pageTreePlugin({ collections: ['pages'] }), // Pages doesn't exist yet!
createPuckPlugin({ pagesCollection: 'pages' }), // Creates Pages too late
]
Symptoms of wrong order
- Tree view only shows one collection when you configured multiple
- Double-clicking a page navigates to the wrong collection
- Console warning:
[payload-page-tree] Collections not found: pages
When order doesn't matter
If you define your collections manually (not auto-generated by plugins), order doesn't matter:
ts// Collections defined in config — order doesn't matter
export default buildConfig({
collections: [Pages, Posts], // Both exist before any plugin runs
plugins: [
pageTreePlugin({ collections: ['pages', 'posts'] }),
createPuckPlugin({
pagesCollection: 'pages',
autoGenerateCollection: false,
}),
],
})
Organization Scoping
For multi-tenant apps where each organization has isolated content, use customizeFolderCollection to add organization-based access control to folders:
tsimport { pageTreePlugin } from '@delmaredigital/payload-page-tree'
import { orgScopedAccess, createOrganizationField } from './access/organization'
pageTreePlugin({
collections: ['pages'],
customizeFolderCollection: (collection) => ({
...collection,
access: orgScopedAccess,
fields: [...collection.fields, createOrganizationField()],
}),
})
This ensures:
- Folders are filtered by the user's active organization in the tree view
- New folders are automatically assigned to the user's organization
- Users can only see and manage folders belonging to their organization
Migration Required: If adding organization scoping to an existing app that already has folders, you'll need to migrate existing folder data to assign them to organizations. Unassigned folders will become inaccessible.
Extensibility
Theming Outside Payload Admin
The tree components use Payload's CSS variables for theming. When using components outside the admin panel, import the default theme:
tsimport '@delmaredigital/payload-page-tree/theme.css'
Custom theme mapping (e.g., shadcn/ui)
css.page-tree-wrapper {
--theme-bg: hsl(var(--background));
--theme-input-bg: hsl(var(--background));
--theme-elevation-0: hsl(var(--background));
--theme-elevation-50: hsl(var(--muted));
--theme-elevation-100: hsl(var(--border));
--theme-elevation-150: hsl(var(--border));
--theme-elevation-400: hsl(var(--muted-foreground));
--theme-elevation-500: hsl(var(--muted-foreground));
--theme-elevation-600: hsl(var(--foreground));
--theme-elevation-700: hsl(var(--foreground));
--theme-elevation-800: hsl(var(--foreground));
--theme-success-500: hsl(var(--primary));
--theme-error-500: hsl(var(--destructive));
--theme-error-50: hsl(var(--destructive) / 0.1);
}
Custom Edit URLs
Customize where "Edit" links navigate (useful for visual editors like Puck):
tsimport { PageTreeClient, GetEditUrlFn } from '@delmaredigital/payload-page-tree/client'
const getEditUrl: GetEditUrlFn = (collection, id, adminRoute) => {
return `${adminRoute}/my-editor/${collection}/${id}`
}
// Pass to PageTreeClient component
<PageTreeClient getEditUrl={getEditUrl} ... />
Building Custom Tree Views
Use buildTreeStructure to create tree data for custom UIs:
tsimport { buildTreeStructure, TreeNode } from '@delmaredigital/payload-page-tree'
// Fetch folders and pages from Payload
const folders = await payload.find({ collection: 'payload-folders', limit: 0 })
const pages = await payload.find({ collection: 'pages', limit: 0 })
// Build tree structure
const tree: TreeNode[] = buildTreeStructure(
folders.docs,
pages.docs.map(p => ({ ...p, _collection: 'pages' })),
{ collections: ['pages'] }
)
Adding to Existing Projects
The plugin safely handles existing content:
- Existing pages keep their slugs — the plugin only generates slugs for new pages or when explicitly requested
- No schema conflicts —
pathSegmentis optional, so existing folders work without modification - Migrate when ready — use the "Regenerate URLs" action to update slugs for specific folders
MIT License — @delmaredigital/payload-page-tree