npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@arevo/payload-pagetree-plugin

v1.0.0

Published

Hierarchical page tree plugin for Payload CMS with drag-and-drop reordering and nested pages

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 identifier
  • fullPath - Auto-computed full path from parent + slug
  • parent - Relationship to parent page
  • isGroup - Checkbox to mark as folder/group
  • sort - 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.tsx

Step 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/tree

Returns 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/move

Move 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 provided
  • fullPath: Computed from parent hierarchy + slug
  • parent: Relationship field to self
  • isGroup: Flag for folder-like pages
  • sort: Numeric value for sibling order

Hooks

The plugin adds several hooks to maintain data integrity:

  1. beforeValidate: Auto-generates slug from title if missing
  2. beforeChange: Computes fullPath and assigns default sort order
  3. afterChange: Cascades path updates to all descendants when parent or slug changes
  4. beforeDelete: Prevents deletion if page has children

Path Computation

When a page is saved, its fullPath is computed by:

  1. Getting the parent's fullPath
  2. Appending the current page's slug
  3. Example: parent /about + slug team = /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:

  1. Ensure you've converted to [[...slug]] catch-all route
  2. Verify you removed the duplicate root page.tsx
  3. Restart your dev server
  4. 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:

  1. Verify the collection is listed in the plugin config
  2. Clear your browser cache
  3. Check that the custom component path is correct
  4. 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:

  1. Check that the afterChange hook is registered
  2. Look for errors in the server logs
  3. 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
  1. Create root page: "Docs" (isGroup: true)
  2. Create sections: "Getting Started", "Guides", "API Reference" (all isGroup: true)
  3. Create content pages under each section
  4. 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 // number

Contributing

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.