@arevo/payload-pagetree-plugin
v1.0.0
Published
Hierarchical page tree plugin for Payload CMS with drag-and-drop reordering and nested pages
Maintainers
Readme
Payload PageTree Plugin
A powerful plugin for Payload CMS that adds hierarchical page tree functionality with drag-and-drop reordering, nested pages, and automatic path computation.
Features
- Hierarchical Page Structure: Create parent-child relationships between pages
- Auto-computed Full Paths: Automatically generates full URL paths based on page hierarchy (e.g.,
/parent/child/grandchild) - Tree View Interface: Beautiful drag-and-drop tree view in the admin panel
- Page Groups: Support for folder-like group pages that organize content
- Automatic Slug Generation: Slugs are automatically generated from page titles
- Move & Reorder: Drag pages to reorder or move them between parents
- Cascade Updates: Moving a parent automatically updates all descendant paths
- Delete Protection: Prevents deletion of pages with children
- REST API Endpoints: Query and manipulate the page tree via API
Installation
Since this is a local plugin, it's already included in your project at src/payload-pagetree/.
If you want to use it in another project, copy the entire payload-pagetree folder to your new project's src/ directory.
Quick Start
1. Add the Plugin to Your Payload Config
// payload.config.ts
import { PageTree } from './payload-pagetree'
export default buildConfig({
// ... other config
plugins: [
PageTree({
collections: ['pages'], // Add any collections you want to be hierarchical
}),
],
})2. Update Your Collection (if needed)
The plugin automatically adds these fields to your collection:
slug- URL-friendly identifierfullPath- Auto-computed full path from parent + slugparent- Relationship to parent pageisGroup- Checkbox to mark as folder/groupsort- Number for ordering among siblings
Important: If your collection already has a slug field (e.g., using slugField()), remove it to avoid conflicts:
// Before
import { slugField } from 'payload'
export const Pages: CollectionConfig = {
fields: [
// ... other fields
slugField(), // Remove this line
],
}
// After
export const Pages: CollectionConfig = {
fields: [
// ... other fields
// Plugin provides slug field automatically
],
}3. Set Up Next.js Routing (Required)
To support nested pages in your Next.js app, follow the Integration Guide below.
Configuration Options
PageTree({
collections: ['pages', 'docs'], // Collections to enable tree functionality
treeLabel?: 'Page Tree', // Optional: Customize the tree view label
})Options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| collections | string[] | Yes | Array of collection slugs to enable tree functionality |
| treeLabel | string | No | Custom label for the tree view interface |
Integration with Next.js
To make your Next.js App Router work with nested pages, you need to update your routing structure.
Step 1: Convert to Catch-All Route
Replace your single-segment route with an optional catch-all route:
Before:
src/app/(frontend)/
├── [slug]/
│ ├── page.tsx
│ └── page.client.tsx
└── page.tsx (root page)After:
src/app/(frontend)/
└── [[...slug]]/
├── page.tsx
└── page.client.tsxStep 2: Update the Page Component
Create or update src/app/(frontend)/[[...slug]]/page.tsx:
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import { cache } from 'react'
import configPromise from '@payload-config'
export async function generateStaticParams() {
const payload = await getPayload({ config: configPromise })
const pages = await payload.find({
collection: 'pages',
draft: false,
limit: 1000,
overrideAccess: false,
pagination: false,
select: {
slug: true,
fullPath: true,
},
})
return pages.docs
?.filter((doc) => doc.slug !== 'home')
.map((doc) => {
// Use fullPath and split into segments for catch-all route
const path = (doc as any).fullPath || doc.slug
const pathWithoutSlash = path.startsWith('/') ? path.slice(1) : path
const slug = pathWithoutSlash.split('/').filter(Boolean)
return { slug }
}) || []
}
type Args = {
params: Promise<{
slug?: string[]
}>
}
export default async function Page({ params: paramsPromise }: Args) {
const { isEnabled: draft } = await draftMode()
const { slug: slugArray } = await paramsPromise
// Convert slug array to string path (or 'home' if undefined/empty)
const slug = slugArray && slugArray.length > 0 ? slugArray.join('/') : 'home'
const decodedSlug = decodeURIComponent(slug)
const page = await queryPageBySlug({ slug: decodedSlug })
if (!page) {
notFound()
}
// Render your page...
}
const queryPageBySlug = cache(async ({ slug }: { slug: string }) => {
const { isEnabled: draft } = await draftMode()
const payload = await getPayload({ config: configPromise })
// Construct the full path for querying (add leading slash)
const fullPath = slug.startsWith('/') ? slug : '/' + slug
// Query by fullPath (supports nested pages)
const result = await payload.find({
collection: 'pages',
draft,
limit: 1,
pagination: false,
overrideAccess: true,
where: {
fullPath: {
equals: fullPath,
},
},
})
return result.docs?.[0] || null
})Step 3: Update Preview Path Generation
Update your preview path utility to use fullPath:
// utilities/generatePreviewPath.ts
export const generatePreviewPath = ({ collection, slug }: Props) => {
if (slug === undefined || slug === null) {
return null
}
const encodedSlug = encodeURIComponent(slug)
const pathPrefix = collectionPrefixMap[collection] || ''
// For pages, slug might already be a fullPath (e.g., "parent/child")
const fullPath = slug.startsWith('/') ? slug : `${pathPrefix}/${slug}`
const encodedParams = new URLSearchParams({
slug: encodedSlug,
collection,
path: fullPath,
previewSecret: process.env.PREVIEW_SECRET || '',
})
return `/next/preview?${encodedParams.toString()}`
}Step 4: Update Collection Config
Update your Pages collection to use fullPath in preview functions:
// collections/Pages/index.ts
export const Pages: CollectionConfig = {
admin: {
livePreview: {
url: ({ data, req }) =>
generatePreviewPath({
slug: (data as any)?.fullPath || data?.slug,
collection: 'pages',
req,
}),
},
preview: (data, { req }) =>
generatePreviewPath({
slug: ((data as any)?.fullPath || data?.slug) as string,
collection: 'pages',
req,
}),
},
// ... rest of config
}Step 5: Update Revalidation Hooks
Update your revalidation hooks to use fullPath:
// collections/Pages/hooks/revalidatePage.ts
export const revalidatePage: CollectionAfterChangeHook<Page> = ({
doc,
previousDoc,
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const fullPath = (doc as any).fullPath || doc.slug
const path = doc.slug === 'home' ? '/' : fullPath.startsWith('/') ? fullPath : `/${fullPath}`
payload.logger.info(`Revalidating page at path: ${path}`)
revalidatePath(path)
revalidateTag('pages-sitemap')
}
// Handle previously published pages
if (previousDoc?._status === 'published' && doc._status !== 'published') {
const prevFullPath = (previousDoc as any).fullPath || previousDoc.slug
const oldPath = previousDoc.slug === 'home' ? '/' : prevFullPath.startsWith('/') ? prevFullPath : `/${prevFullPath}`
payload.logger.info(`Revalidating old page at path: ${oldPath}`)
revalidatePath(oldPath)
revalidateTag('pages-sitemap')
}
}
return doc
}
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
if (!context.disableRevalidate) {
const fullPath = (doc as any)?.fullPath || doc?.slug
const path = doc?.slug === 'home' ? '/' : fullPath?.startsWith('/') ? fullPath : `/${fullPath}`
revalidatePath(path)
revalidateTag('pages-sitemap')
}
return doc
}Step 6: Update Sitemap Generation
Update your sitemap to use fullPath:
// app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts
const results = await payload.find({
collection: 'pages',
overrideAccess: false,
draft: false,
depth: 0,
limit: 1000,
pagination: false,
where: {
_status: {
equals: 'published',
},
},
select: {
slug: true,
fullPath: true, // Add fullPath to select
updatedAt: true,
},
})
const sitemap = results.docs
?.filter((page) => Boolean(page?.slug))
.map((page) => {
// Use fullPath if available, fallback to slug
const path = (page as any)?.fullPath || page?.slug
const url = page?.slug === 'home' ? `${SITE_URL}/` : `${SITE_URL}${path.startsWith('/') ? path : `/${path}`}`
return {
loc: url,
lastmod: page.updatedAt || dateFallback,
}
})API Endpoints
The plugin automatically adds two REST endpoints:
Get Tree Structure
GET /api/pagetree/:collection/treeReturns the complete hierarchical tree for a collection.
Response:
{
"tree": [
{
"id": "123",
"title": "About",
"slug": "about",
"fullPath": "/about",
"isGroup": false,
"sort": 0,
"children": [
{
"id": "456",
"title": "Team",
"slug": "team",
"fullPath": "/about/team",
"isGroup": false,
"sort": 0,
"children": []
}
]
}
]
}Move Node
POST /api/pagetree/:collection/moveMove a page to a new parent or update its sort order.
Request Body:
{
"id": "456",
"newParent": "789",
"newSort": 10
}Response:
{
"ok": true,
"node": { ... }
}Admin Interface
The plugin replaces the default list view with a tree view that provides:
- Drag-and-drop: Reorder pages or move them between parents
- Visual hierarchy: Clear parent-child relationships
- Expand/collapse: Navigate large page trees easily
- Quick actions: Edit, view, or delete pages from the tree
- Full-path display: See the complete URL path for each page
How It Works
Field Management
The plugin intelligently adds fields only if they don't already exist in your collection:
slug: Auto-generated from title if not providedfullPath: Computed from parent hierarchy + slugparent: Relationship field to selfisGroup: Flag for folder-like pagessort: Numeric value for sibling order
Hooks
The plugin adds several hooks to maintain data integrity:
- beforeValidate: Auto-generates slug from title if missing
- beforeChange: Computes fullPath and assigns default sort order
- afterChange: Cascades path updates to all descendants when parent or slug changes
- beforeDelete: Prevents deletion if page has children
Path Computation
When a page is saved, its fullPath is computed by:
- Getting the parent's
fullPath - Appending the current page's
slug - Example: parent
/about+ slugteam=/about/team
If a parent is moved or its slug changes, all descendants are automatically updated in a breadth-first cascade.
Best Practices
Page Structure
Root Pages (parent: null)
├── About (slug: about, fullPath: /about)
│ ├── Team (slug: team, fullPath: /about/team)
│ └── History (slug: history, fullPath: /about/history)
├── Products (slug: products, fullPath: /products, isGroup: true)
│ ├── Category A (slug: category-a, fullPath: /products/category-a)
│ └── Category B (slug: category-b, fullPath: /products/category-b)
└── Contact (slug: contact, fullPath: /contact)Using Groups
Mark pages as groups (folders) when they:
- Organize other pages but don't have their own content
- Should be displayed differently in navigation
- Need special handling in your frontend
Slug Naming
- Keep slugs short and descriptive
- Use lowercase letters and hyphens
- Avoid special characters
- The plugin auto-slugifies titles, but you can override
Performance
- The plugin uses efficient BFS algorithms for cascade updates
- Tree queries are optimized with proper indexing
- For very large trees (1000+ pages), consider pagination in the admin view
Troubleshooting
404 Errors on Nested Pages
Problem: Pages show in admin but return 404 on the frontend.
Solution:
- Ensure you've converted to
[[...slug]]catch-all route - Verify you removed the duplicate root
page.tsx - Restart your dev server
- Check that pages are published (not drafts)
Slug Field Conflicts
Problem: Error about duplicate slug fields.
Solution: Remove slugField() from your collection config - the plugin provides its own slug field with additional functionality.
Pages Not Showing in Tree View
Problem: Tree view is empty or doesn't show.
Solution:
- Verify the collection is listed in the plugin config
- Clear your browser cache
- Check that the custom component path is correct
- Ensure the collection has at least one page
FullPath Not Updating
Problem: Moving a page doesn't update child paths.
Solution: This should happen automatically. If it doesn't:
- Check that the
afterChangehook is registered - Look for errors in the server logs
- Try saving the parent page again to trigger cascade
Examples
Creating a Documentation Site Structure
// docs/
// getting-started/
// installation
// quick-start
// guides/
// authentication
// deployment
// api-reference/
// collections
// fields- Create root page: "Docs" (isGroup: true)
- Create sections: "Getting Started", "Guides", "API Reference" (all isGroup: true)
- Create content pages under each section
- Drag to reorder as needed
Building a Multi-level Navigation
Query the tree structure from your frontend:
const response = await fetch('/api/pagetree/pages/tree')
const { tree } = await response.json()
// Render navigation
function renderNav(items) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
<a href={item.fullPath}>{item.title}</a>
{item.children.length > 0 && renderNav(item.children)}
</li>
))}
</ul>
)
}TypeScript Support
The plugin is fully typed. TypeScript will infer the correct types when you query pages:
const page = await payload.findByID({
collection: 'pages',
id: '123',
})
// TypeScript knows about these fields:
page.slug // string
page.fullPath // string
page.parent // string | Page | null
page.isGroup // boolean
page.sort // numberContributing
Since this is a local plugin, feel free to modify it for your needs:
- Plugin core:
src/payload-pagetree/index.ts - Admin components:
src/payload-pagetree/admin/ - Shared utilities:
src/payload-pagetree/shared/
License
MIT
Credits
Built for Payload CMS v3+ with Next.js App Router support.
