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

payload-nested-docs-page-tree

v1.4.4

Published

Page tree plugin for Payload nested docs collections

Readme

payload-nested-docs-page-tree

Companion admin plugin for @payloadcms/plugin-nested-docs.

Adds a nested tree list view for nested docs collections in Payload admin, with visual hierarchy shading, live URL path previews, separate reorder and parent-move interactions, and status badges for published / changed / draft documents.

It works alongside @payloadcms/plugin-nested-docs. It does not replace nested docs persistence, breadcrumbs generation, or routing.

Tested with Payload 3.81 and Next.js 16.2.

Install

pnpm add payload-nested-docs-page-tree

Quick Setup

@payloadcms/plugin-nested-docs should already be installed, and each target collection should already have:

  • a nested docs parent field
  • a nested docs breadcrumbs field
  • a top-level admin.useAsTitle field

These fields may live inside presentational containers — tabs, rows, collapsibles, and unnamed groups — because Payload still stores them at the top level of the document. Fields inside a named tab or named group are nested in the data and are not supported.

Add nestedDocsPageTreePlugin(...) right after nestedDocsPlugin(...):

import { nestedDocsPlugin } from '@payloadcms/plugin-nested-docs'
import { nestedDocsPageTreePlugin } from 'payload-nested-docs-page-tree'

export const plugins = [
  nestedDocsPlugin({
    // your existing nested docs config
  }),
  nestedDocsPageTreePlugin({
    collections: ['pages'],
  }),
]

Refresh the admin import map:

payload generate:importmap

What It Adds

  • replaces the collection list view with a nested tree table
  • adds visual hierarchy shading for nested pages
  • shows the live URL path for each page
  • preserves sorting, filters, pagination, bulk selection, and row actions
  • adds POST /:id/move for intentional parent changes
  • adds POST /:id/reorder for orderable-only sibling reordering
  • keeps reorder drags scoped to the same parent or root level
  • marks the root page with slug home using a home icon on the title link
  • hides the read-only breadcrumbs field by default

Feature Preview

Visual hierarchy shading

Homepage icon

Live URL path preview

Drag-And-Drop Modes

Parent moves and orderable reordering are separate interactions.

For collections using Payload orderable, the reorder handle is shown only when the current sort is the orderable field. Reordering only changes the order key and is limited to documents under the same parent, or documents at the root level. A reorder drag cannot change a document's parent.

https://github.com/user-attachments/assets/b25ffa1a-a6bd-45cf-bce8-56ba6cdf7e72

Parent moves are hidden by default because they change the page hierarchy. Editors can enable the parent-move handle with the Edit Hierarchy button when they intentionally want to move pages between parents. Drag a page onto another page to make it a child of that page. Drag it between root pages to move it back to the root level.

Edit hierarchy mode

https://github.com/user-attachments/assets/618d5e53-5918-40be-9932-0f516e5e82ba

Parent move to child

https://github.com/user-attachments/assets/4cb25109-e515-4955-8503-39fddac0020f

Parent move back to root

https://github.com/user-attachments/assets/470cb5b3-61c8-4b5b-a6e4-5e855f58a0e4

Same-parent reorder guard

https://github.com/user-attachments/assets/2513ff04-192e-4fdf-808f-6004f55d871c

Internally, parent moves call this plugin's /:id/move endpoint. That endpoint updates the nested docs parent field through Payload's local API, while @payloadcms/plugin-nested-docs continues to own parent and breadcrumb behavior through its normal fields and hooks.

Publishing moves

On a drafts-enabled collection a move is written through the drafts system, so by default it is staged as a draft: the tree updates immediately and the page shows a changed badge, but the live URL/path changes only when the page is next published. This is the safe default — publishing a move for a page that also has other pending edits would publish those edits too.

Set publishOnMove: true to publish a move as soon as it happens — but only when it is safe to:

nestedDocsPageTreePlugin({
  collections: ['pages'],
  publishOnMove: true,
})

With publishOnMove enabled, a move is published immediately only if the moved page had no unpublished changes beforehand (its latest version was already published). If the page has pending draft edits (a changed or draft-only page), the move stays staged exactly as before, so in-progress edits are never published as a side effect of a move. Collections without drafts always move live and are unaffected by this option.

Publishing a move also republishes the moved page's descendants, so their live URLs follow the new parent. Since the live site really did change, a published move does trigger your deploy hook, once for the whole subtree - see Deploy Hooks. Reorders and staged moves still do not. You do not need to change your hook when enabling this option.

On a localized collection the parent field is shared across locales but breadcrumbs are not: publishing a move recomputes breadcrumbs only for the locale the move was made in, so other locales keep their previous URL until they are next published. If that matters for your setup, leave publishOnMove off.

Home Indicator

By default, the home icon is enabled only for the pages collection.

nestedDocsPageTreePlugin({
  collections: ['pages'],
})

For custom page collection slugs, pass an exact allow-list:

nestedDocsPageTreePlugin({
  collections: ['page-tree', 'categories'],
  homeIndicator: {
    collections: ['page-tree'],
  },
})

To disable the home icon everywhere:

nestedDocsPageTreePlugin({
  collections: ['pages'],
  homeIndicator: false,
})

Status Badges

The tree view supports three document states:

  • published: live and up to date
  • changed: live, but has unpublished changes
  • draft: not published

Badge colors use Payload theme colors by default. To override badge labels or status colors, pass a badges object. Custom colors are treated as one base color per status and are adapted for both light and dark Payload themes:

nestedDocsPageTreePlugin({
  collections: ['pages'],
  badges: {
    colors: {
      // Example: use a custom green / blue / orange palette.
      published: '#bbf3b0',
      changed: '#b9eaf3',
      draft: '#f8d5a7',
    },
    labels: {
      // Example: use custom labels for states.
      published: 'Live',
      changed: 'Has Changes',
      draft: 'Draft Only',
    },
  },
}),

labels and colors are optional partial overrides. Missing entries fall back to the built-in Payload-themed defaults for published, changed, and draft states.

Configuration

  • collections: target collection slugs
  • parentFieldSlug: defaults to 'parent'
  • breadcrumbsFieldSlug: defaults to 'breadcrumbs'
  • defaultLimit: defaults to 100
  • hideBreadcrumbs: defaults to true
  • disabled: defaults to false
  • homeIndicator: defaults to { collections: ['pages'] }; set to false to disable
  • publishOnMove: defaults to false. When true, a move is published immediately if the moved page had no unpublished changes; pages with pending edits stay staged. See Publishing moves.
  • badges: optional label and color overrides for published, changed, and draft
  • diagnostics: defaults to false. Enables structured diagnostic logging for tree-related publish/draft regressions; see below.

Diagnostics Mode

If a page-tree move or reorder is doing something unexpected, enable diagnostics and reproduce. Each page-tree-triggered write emits one or more structured events on the dev server stdout:

nestedDocsPageTreePlugin({
  collections: ['pages'],
  diagnostics: true,
})

Or with a custom sink:

nestedDocsPageTreePlugin({
  collections: ['pages'],
  diagnostics: {
    enabled: true,
    logger: (event) => req.payload.logger.info({ pageTree: event }),
  },
})

Each event is one line tagged [payload-nested-docs-page-tree] followed by the event source (move-endpoint:enter, move-endpoint:ok, move-endpoint:error, reorder-endpoint:enter, reorder-endpoint:ok, reorder-endpoint:error, page-tree-change:after, page-tree-change:status-flip) and a JSON payload that includes:

  • flow: id shared by every event for one logical operation
  • publishedMainRowBefore / publishedMainRowAfter: fresh reads of the public/published row (draft: false)
  • before / after / changed: projected diffs for _status, the parent field, and the orderable field when present

If the published main row goes from published to anything else as a result of a page-tree change, the plugin additionally emits a page-tree-change:status-flip WARN line.

Diagnostics is opt-in and adds extra reads per operation. Leave it off in production unless you are actively investigating.

Drag-And-Drop Is Triggering A Deploy?

A drag-and-drop parent move calls payload.update() on the draft only. The published version of the live site is never touched. So in most setups, dragging a page does not trigger any rebuild and you can skip this section.

When you can skip this section

  • The default Payload website template on Vercel (or any host using Next.js ISR), with drafts and autosave on. The template's afterChange hook only calls revalidatePath and revalidateTag from next/cache. Those just clear the edge cache. They do not trigger a Vercel build, do not consume build minutes, and do not change what visitors see when the published HTML has not changed.
  • Any setup where your afterChange hooks only do in-process cache work (revalidatePath, revalidateTag, in-memory caches, etc.).

When you need the one-line fix

You need the fix if you wrote an afterChange hook that calls something external or expensive on every save. Common cases:

  • Cloudflare Pages / Netlify / Vercel Deploy Hooks (fetch(DEPLOY_HOOK_URL)) - these trigger full rebuilds and burn build minutes.
  • GitHub Actions repository_dispatch triggers.
  • Manually-invoked SSG rebuilds.
  • Publish notifications (email, Slack) on status transitions.
  • Heavy search reindex jobs (Algolia, Meilisearch full-document push).

Why a tree move trips these: a typical deploy hook fires when previousDoc?._status === 'published' so that it catches unpublish events too. A tree move on a published doc can match that condition, but the live site has not actually changed. Without the fix, every drag can fire your deploy.

The fix

Add one line at the top of your hook. The plugin sets a flag on Payload's hook context for every page-tree write that leaves the live site unchanged, and your hook reads it to bail out early:

import { pageTreeMoveContextKey } from 'payload-nested-docs-page-tree'

// at the top of your afterChange hook:
if (req.context?.[pageTreeMoveContextKey]) return

The rule is simply rebuild when the live site changed, so this one line stays correct in every configuration:

| What the editor did in the tree | Live site changed? | Your deploy hook | | --- | --- | --- | | Reordered siblings | no - only the order key was written | does not fire | | Moved a page, default settings | no - the move is staged as a draft | does not fire | | Moved a page with publishOnMove on | yes - the page and its descendants have new URLs | fires once |

Only the last row publishes anything, which is why it is the only row that rebuilds. It fires once for the whole subtree, not once per descendant. Enabling publishOnMove needs no change to your hook - the same line already does the right thing.

To rebuild after every page-tree drag, including reorders, just leave the guard out. That also rebuilds on staged moves, which change nothing live.

This goes in your hook - the one that calls the deploy webhook. Not in any of the template's stock files.

Full example:

import type { CollectionAfterChangeHook } from 'payload'

import { pageTreeMoveContextKey } from 'payload-nested-docs-page-tree'

export const triggerDeployOnPublishedChange: CollectionAfterChangeHook = async ({
  doc,
  previousDoc,
  req,
}) => {
  // -- plugin opt-out --
  if (req.context?.[pageTreeMoveContextKey]) return

  // -- your deploy logic (example) --
  // Fire on publish, republish, or unpublish - every transition the live site cares about.
  if (doc._status === 'published' || previousDoc?._status === 'published') {
    // POST to your Cloudflare / Netlify / Vercel deploy hook here
  }
}

See dev/lib/rebuild.ts for the full Cloudflare deploy hook example used by the dev playground.

Development

For local plugin development, use the internal dev/ app:

pnpm install
pnpm dev
pnpm generate:types
pnpm generate:importmap

Open http://localhost:3000/admin and sign in with:

The dev app creates this user automatically on startup. After signing in, use the seed the database button on the dashboard to add the sample pages.

Plugin source is in src/. The internal test app is in dev/.

For checks:

pnpm test:int
pnpm exec tsc --noEmit

Test in Another Project

For release validation, test the packed artifact instead of a live source-folder dependency:

pnpm build
pnpm pack

Then in the external consumer app:

pnpm add /path/payload-nested-docs-page-tree-*.tgz