magicspon

payload navigation

by magicspon
Admin customization
v3
^3.84.1

Overview

Summary generated from the README

Adds navigation and menu item collections with a drag-and-drop tree builder in the Payload admin. Menu items can be URL, internal page, custom or passive links, with configurable nesting depth. A callback resolves internal page URLs, and a precomputed items tree is written to each navigation document. Requires Payload 3.

Features

  • Drag-and-drop tree builder in the admin panel
  • Four link types: URL, internal page, custom and passive
  • Configurable maximum nesting depth
  • Callback to resolve URLs for internal documents
  • Blocks deletion of pages referenced by menu items
  • Extra menu item fields pass through to the output tree

Use cases

  • Build main and footer navigation menus that editors manage visually
  • Expose a ready-made nested menu tree to a frontend through the API
  • Link menu items to pages while keeping referenced pages protected from deletion

From the author

A blank template to get started with Payload 3.0

Installation

npm i @spon/payload-navigation

2

Stars

0

Forks

0

Open Issues

4 months ago

Last Updated

34

Weekly Downloads

v0.4.1

npm Version

175.8 KB

Unpacked Size

5

Dependencies

GitHub stars

Trends appear after 7 days of data. Collecting since Oct 22 — 3 daily snapshots so far.

npm weekly downloads

Trends appear after 7 days of data. Collecting since Oct 22 — 3 daily snapshots so far.

Each point is npm's rolling 7-day download count.

Health Score

48/100Fair

Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.

Badge for your README

Payload Directory health badge
Markdown
[![Payload Directory](https://payloaddirectory.dev/badge/1242722491-root.svg)](https://payloaddirectory.dev/plugins/1242722491-root)
HTML
<a href="https://payloaddirectory.dev/plugins/1242722491-root"><img src="https://payloaddirectory.dev/badge/1242722491-root.svg" alt="Payload Directory"></a>
Updated May 29, 2026Created May 18, 2026Published May 29, 2026

README

@spon/payload-navigation

A Payload CMS plugin for managing navigation menus. Adds a drag-and-drop tree UI to the admin panel, supports multiple link types including internal page relationships, and writes a precomputed clean tree to the navigation document for easy frontend consumption.

Features

  • Drag-and-drop menu builder in the Payload admin
  • Four link types: URL, Internal page, Custom, Passive
  • Configurable nesting depth
  • Automatically resolves internal page URLs via a callback
  • Prevents deletion of pages that are referenced by a menu item
  • Cascades deletion of menu items when a navigation is deleted
  • Writes a precomputed items field (clean JSON tree) to the navigation document on every change

Installation

pnpm add @spon/payload-navigation

Setup

Add the plugin to your payload.config.ts:

import { navigationPlugin } from '@spon/payload-navigation'

export default buildConfig({
  plugins: [
    navigationPlugin({
      internalCollections: ['pages'],
      maxDepth: 3,
      resolveInternalUrl: async ({ id, collection, payload }) => {
        const doc = await payload.findByID({ collection, id, depth: 0 })
        return doc?.slug ? `/${doc.slug}` : '#'
      },
    }),
  ],
})

Options

OptionTypeDefaultDescription
disabledbooleanfalseDisable the plugin without removing it
internalCollectionsstring[][]Collection slugs that can be linked as internal pages
maxDepthnumber3Maximum nesting depth for menu items
resolveInternalUrlResolveInternalUrlReturns #idAsync function to resolve a URL from an internal document
menuItemOmit<Partial<CollectionConfig>, 'slug'>{}Extend or override the menu_item collection
navigationOmit<Partial<CollectionConfig>, 'slug'>{}Extend or override the navigation collection

resolveInternalUrl

Called whenever a menu item of type internal is saved. Use it to resolve the document's URL from its ID.

type ResolveInternalUrl = (args: {
  id: string
  collection: string
  payload: BasePayload
}) => Promise<string>

menuItem and navigation

Both options accept any CollectionConfig property except slug. The merge behaviour differs by property type:

PropertyBehaviour
fieldsAppended after plugin fields
hooksRun after plugin hooks (plugin invariants preserved)
adminShallow-merged with plugin defaults
accessShallow-merged — read defaults to () => true
Everything elseConsumer value overrides plugin default
navigationPlugin({
  menuItem: {
    // extra fields appear in the drawer and pass through to the items tree output
    fields: [
      { name: 'badge', type: 'text' },
      { name: 'icon', type: 'text' },
    ],
    hooks: {
      afterChange: [({ doc }) => revalidateCache(doc)],
    },
    admin: { hidden: false }, // un-hide the collection
    access: {
      create: ({ req }) => req.user?.role === 'admin',
      update: ({ req }) => req.user?.role === 'admin',
      delete: ({ req }) => req.user?.role === 'admin',
    },
  },
  navigation: {
    access: {
      create: ({ req }) => req.user?.role === 'admin',
      delete: ({ req }) => req.user?.role === 'admin',
    },
    versions: { drafts: true },
  },
})

Collections

The plugin registers two collections:

  • navigation — A named menu (e.g. "Main Nav", "Footer"). Contains the visual builder UI and a hidden items field with the precomputed tree.
  • menu_item — Individual items within a menu. Managed through the builder UI, not directly.

The items field

The navigation document's items field contains a precomputed clean JSON tree, built from the menu_item collection on every read:

type NavigationMenuItem<TExtra extends Record<string, unknown> = Record<string, never>> = {
  id: string
  title: string
  type: string
  href: string // resolved URL for all types
  depth: number
  parent: string | null
  collapsed: boolean
  children?: NavigationMenuItem<TExtra>[]
} & TExtra

Any extra fields added to menu_item via the menuItem.fields option are automatically included in each node. Use the TExtra generic to type them in your frontend:

// If you added { name: 'badge', type: 'text' } to menuItem.fields:
type MyNavItem = NavigationMenuItem<{ badge?: string }>

const nav = await payload.find({
  collection: 'navigation',
  where: { slug: { equals: 'main' } },
})

const tree = nav.docs[0]?.items as MyNavItem[]
TypeDescription
urlAn absolute or relative web address
internalA document from one of your internalCollections
customAny string value (e.g. an anchor #section)
passiveA label with no link (for parent-only items)

React components

Tree UI components are exported from @spon/payload-navigation/client for embedding in custom admin views:

import { MenuTree, TreeItem, DeleteMenuItem } from '@spon/payload-navigation/client'

These are all client components ('use client').

Development

# Install dependencies
pnpm install

# Copy env and start the dev server
cp dev/.env.example dev/.env
pnpm dev

# Type check
pnpm typecheck

# Lint
pnpm lint

# Unit tests
pnpm test:unit

# E2E tests (requires dev server running)
pnpm test:e2e

# Build
pnpm build

Similar plugins