payload-page-tree
v0.3.10
/

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 pages and posts collections 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

  1. Create folders in the Payload admin with a pathSegment (e.g., "appeals", "2024")
  2. Nest folders to create hierarchy (e.g., "2024" under "appeals")
  3. Create a page and select a folder from the dropdown
  4. 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,
})
OptionTypeDefaultDescription
collectionsstring[]['pages', 'posts']Collections to enable (auto-filters to existing)
folderSlugstring'payload-folders'Folder collection slug
segmentFieldNamestring'pathSegment'Field name on folders
pageSegmentFieldNamestring'pageSegment'Field name on pages
disabledbooleanfalseDisable hooks while keeping schema
adminView.enabledbooleantrueShow tree view in admin
adminView.pathstring'/page-tree'URL path for tree view
buildSlugfunctionCustom slug builder (details)
customizeFolderCollectionfunctionCustomize 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:

  1. Existing pages keep their slugs — the plugin only generates slugs for new pages or when explicitly requested
  2. No schema conflictspathSegment is optional, so existing folders work without modification
  3. Migrate when ready — use the "Regenerate URLs" action to update slugs for specific folders

MIT License — @delmaredigital/payload-page-tree