what-works-global

paths

by what-works-global
payload-packages
Redirects & URLs
v3
>=3.54.0 <4

Overview

Summary generated from the README

Stores a computed, indexed path on each document so URLs resolve with a single query and slugs no longer need to be globally unique. It works with nested-docs, a plain parent relationship, or flat collections, and cascades path changes through the hierarchy. A resolver serves found, not-found and redirect results, with a Next.js layer included. Requires Payload 3.54 or newer.

Features

  • Computed path field with publish-time uniqueness checks, optionally scoped per tenant
  • Hierarchy cascades via nested-docs or a built-in parent strategy
  • Prefix-free storage with a virtual url field and configurable prefixes
  • Resolver with pluggable pagination and chaining of multiple collections on one route
  • Pluggable caches including a Next.js unstable_cache adapter
  • Boot-time backfill and integrity scripts, plus an optional frontend edit button

Use cases

  • Allow nested and root pages to share the same slug, such as /about/contact and /contact
  • Serve pages and posts from a single catch-all route in a Next.js app
  • Run multi-tenant sites where each tenant has its own independent URL space

From the author

Stored, queryable document paths for Payload page trees — a computed `path` field with publish-time uniqueness, hierarchy cascades (nested-docs or standalone), prefix-free storage with a virtual `url`, an onInit backfill, and a framework-agnostic resolver with pluggable caches (Next.js unstable_cache adapter included).

Installation

npm i @whatworks/payload-paths

15

Stars

1

Forks

1

Open Issues

2 weeks ago

Last Updated

0

Weekly Downloads

v0.3.1

npm Version

443.5 KB

Unpacked Size

0

Dependencies

Repository stars

Trends appear after 7 days of data. Daily snapshots have not started yet.

Shared by 12 plugins in what-works-global/payload-packages.

npm weekly downloads

Trends appear after 7 days of data. Daily snapshots have not started yet.

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

Health Score

56/100Good

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/1064440892-paths.svg)](https://payloaddirectory.dev/plugins/1064440892-paths)
HTML
<a href="https://payloaddirectory.dev/plugins/1064440892-paths"><img src="https://payloaddirectory.dev/badge/1064440892-paths.svg" alt="Payload Directory"></a>
Updated Oct 6, 2026Created Sep 26, 2025Published Jul 24, 2026

README

@whatworks/payload-paths

@whatworks/payload-paths

 

Stored, queryable document paths for Payload page trees.

Instead of a globally-unique slug and rebuilding each document's URL at request time, this plugin stores a computed, indexed path on every document and resolves a URL with a single indexed query. That one change removes the unique-slug constraint — /about/contact and /contact can finally coexist — and comes with the safety rails a stored path needs: publish-time uniqueness, hierarchy cascades, drift repair, and a framework-agnostic resolver.

  • Duplicate slugs at different levels — uniqueness moves from slug to the full path, enforced per document (and per tenant, if you scope it). Two siblings can't silently claim the same URL; a nested page and a root page can share a slug.
  • Works with or without nested-docs — auto-detects its strategy: @payloadcms/plugin-nested-docs (it owns the cascade), a bare parent relationship (the plugin runs its own cascade), or flat. Paths are computed from the parent chain, never from parsed breadcrumb strings.
  • Multi-tenant — scope the path space by a tenant (or any) field; uniqueness and resolution are per scope.
  • Prefix without rewrites — paths are stored prefix-free; a /blog prefix is applied at the edges (a virtual url field on read, the resolver strips it on the way in). Changing a prefix is pure config — no stored data to migrate.
  • Multiple collections on one route — compose per-collection resolvers into a chain (pages + posts from one [[...slug]]), ranked by array order for segments and by prefix specificity for pathnames.
  • Pluggable routing policy — the /page/N pagination scheme is the default, not the law: rename the segment, drop the page-1 redirect, swap in your own scheme, or turn pagination off — per resolver.
  • Publish-time integrity — drafts (including autosave) never block on collisions; the check fires when a document would go public, with a friendly error on the slug field. Re-parenting under a descendant, cycles, and subtree-move collisions are all caught before the save.
  • Draft-aware, correctly — the Next resolver reads draftMode() itself and always passes draft explicitly, so it needs nothing from a draft-aware getPayload wrapper and never double-filters. Pass a plain instance.
  • Self-healing — an onInit pass repairs documents with a null path (created before install, imported directly, or left by a bypassed hook); backfillPaths, verifyPathIntegrity, and checkPathsAdoption are exposed for scripts and CI.
  • Framework-agnostic serving — a WHATWG-only resolver returns found / not-found / redirect as values for any framework; a thin @whatworks/payload-paths/next layer adds draftMode, notFound(), redirect(), generateStaticParams, and cache wiring for Next 15 and 16.
  • Pluggable cache — no-op by default (always correct), in-memory, the Next.js unstable_cache/revalidateTag adapter, or your own.

Why not just a unique slug?

The common Payload setup makes slug globally unique and, at request time, rebuilds each document's path (often by parsing breadcrumbs[last].url) to match the URL — then loads the document by its leaf slug. That only works because slugs are unique, so /about/contact and /contact cannot both exist, and a forgotten select: { breadcrumbs: true } silently yields a wrong path. Storing the path instead makes resolution a single indexed path lookup, lets the database enforce real URL uniqueness, and turns "did you select breadcrumbs?" into a loud error.

Install

pnpm add @whatworks/payload-paths

next and react are optional peer dependencies — needed only for the @whatworks/payload-paths/next entries (next + react) and the /client edit button (react). The core plugin, resolver, and cache adapters import neither. Requires payload >= 3.54.

Entry points

ImportContainsPulls in next?
@whatworks/payload-pathsThe plugin (pathsPlugin), config helpers, field helpers, and every script utility (backfill, adoption, integrity)No
@whatworks/payload-paths/resolverThe framework-agnostic resolver (createPathsResolver, createResolverChain) and pagination primitivesNo
@whatworks/payload-paths/cacheFramework-free cache adapters (noopPathsCache, memoryPathsCache)No
@whatworks/payload-paths/clientThe floating edit button (PathsEditButton, usePathsEditButton) — framework-agnostic ReactNo (react only)
@whatworks/payload-paths/next/pluginConfig-side Next sugar for payload.config.ts: nextPathsPlugin, nextPathsCache, revalidatePathsOnChangeYes (next/cache only)
@whatworks/payload-paths/nextRequest-time Next sugar: createPathResolver, createMultiPathResolver, createGenerateStaticParams, NextPathsEditButtonYes (incl. navigation)
@whatworks/payload-paths/next/exit-previewcreateExitPreviewRoute for route handlers (safe in app-route modules)Yes (next/headers only)

Why the plugin has its own entry. A Payload config is imported by the REST route handlers, which Next bundles as app-route route modules. next/navigation (used by the request-time resolver in /next) resolves an app-router-context that Next only vendors for app-page/pages — not app-route — so importing anything from /next into payload.config.ts crashes the API with MODULE_UNPARSABLE. Import nextPathsPlugin from /next/plugin (which only touches next/cache) in the config; import the resolver from /next in your pages. The config helpers are still re-exported from /next for back-compat, but don't import them there from a Payload config.

Quick start

Define the shared config once, in a module imported by both your Payload config and your route handler, so prefixes and home slugs can never drift:

// paths.config.ts
import { definePathsConfig } from '@whatworks/payload-paths'

export const pathsConfig = definePathsConfig({
  collections: {
    pages: {}, // nested-docs or parent auto-detected; served at the root
    posts: { prefix: '/blog' }, // flat collection under /blog
  },
})
// payload.config.ts
import { nestedDocsPlugin } from '@payloadcms/plugin-nested-docs'
import { createNestedDocsGenerateURL } from '@whatworks/payload-paths'
import { nextPathsPlugin } from '@whatworks/payload-paths/next/plugin'
import { pathsConfig } from './paths.config'

export default buildConfig({
  // ...
  plugins: [
    nestedDocsPlugin({
      collections: ['pages'],
      // Keep the admin breadcrumb URLs matching the stored paths:
      generateURL: createNestedDocsGenerateURL({ homeSlug: 'home' }),
    }),
    // MUST come after nestedDocsPlugin so the breadcrumbs field exists.
    nextPathsPlugin(pathsConfig),
  ],
})
// app/(frontend)/[[...slug]]/page.tsx
import config from '@payload-config'
import { createGenerateStaticParams, createPathResolver } from '@whatworks/payload-paths/next'
import { getPayload } from 'payload'
import { pathsConfig } from '../../../paths.config'

const getPayloadInstance = () => getPayload({ config })

const resolvePage = createPathResolver({
  collection: 'pages',
  config: pathsConfig,
  getPayload: getPayloadInstance,
})

export const generateStaticParams = createGenerateStaticParams({
  collection: 'pages',
  config: pathsConfig,
  getPayload: getPayloadInstance,
})

export default async function Page({ params }: PageProps<'/[[...slug]]'>) {
  // Single indexed `path` lookup; notFound()/redirect() handled internally.
  const { doc, pageNumber } = await resolvePage({ params })
  // render doc…
}

That's it: createPathResolver runs a single indexed path lookup (deduped per request with React cache, draft-aware via draftMode()), handles /page/N pagination, and calls notFound() / redirect() for you. It returns { collection, doc, draft, pageNumber?, path, url }.

Typing the doc. createPathResolver<Page>({ … }) types doc as your generated Page. To key the type off the collection slug automatically, use Payload's DataFromCollectionSlug<'pages'> as the type argument.

Not on Next.js? Use createPathsResolver from @whatworks/payload-paths/resolver and pathsPlugin from the core entry — see below.

How resolution works

For each request the resolver:

  1. Normalizes the input — either a [[...slug]] param (segments) or an already-built pathname — stripping the collection prefix and any trailing slash.
  2. Tries an exact match on the stored path (a single indexed query). A real document at /docs/page/2 therefore always beats pagination parsing.
  3. On a miss, applies the pagination strategy (default /page/N). /…/page/1 becomes a redirect to the canonical bare path; /…/page/2+ resolves the base document and returns its pageNumber; anything else is not-found.

Public reads filter _status: published and are served through the cache; draft reads (preview) bypass the cache and read the latest version. The Next layer detects draft mode from draftMode() — you always pass a plain Payload instance, never a draft-aware wrapper.

Strategies

The strategy is auto-detected per collection, or set explicitly:

StrategyHierarchyCascade ownerWhen
flatnone (/slug)—no parent field
nested-docsparent + breadcrumbsthe nested-docs pluginnested-docs manages the collection
parentparent onlythis pluginnesting without nested-docs (createParentField builds the field)

For the parent strategy without nested-docs, add the self-referencing field with the helper:

import { createParentField } from '@whatworks/payload-paths'

const Docs: CollectionConfig = {
  slug: 'docs',
  fields: [
    /* … */
    createParentField('docs'), // indexed relationship, blocks self-parent
  ],
}

Configuration

Author the shared config with definePathsConfig (for inference), then spread it into pathsPlugin/nextPathsPlugin and every resolver.

Per-collection options

OptionDefaultPurpose
prefix''URL prefix ('/blog'). Not stored — applied at the edges, so changing it is pure config.
strategy'auto''auto' | 'flat' | 'nested-docs' | 'parent'.
homeSluginherits pluginSlug of the document that becomes the root (path: '/'); false to disable.
slugField'slug'Field the path segments are built from.
parentField'parent'Self-referencing relationship for nested-docs/parent.
breadcrumbsField'breadcrumbs'Field name used only for 'auto' strategy detection.
scopeField—Multi-tenant partition field; uniqueness and resolution become per-scope.
urlField'url'Name of the injected virtual URL field, or false to skip it.
duplicateSlugSuffix'-copy'Suffix appended on document duplicate so the copy gets its own path; false to leave slugs.

Plugin options

Everything above, plus:

OptionDefaultPurpose
homeSlug'home'Default homeSlug for all collections.
cacheno-opCache invalidated on path changes. nextPathsPlugin defaults this to the unstable_cache adapter.
onPathChanged—Handler (or array) fired after any path change, including cascaded descendants.
backfill'fix'Boot-time null-path repair: 'fix' | 'check' | 'off'. A healthy collection costs one count.
maxCascadePreflight500Subtrees larger than this skip the pre-save collision pre-flight (with a warning).
dropStaleSlugUniqueIndextrueOn Mongo, drop a legacy unique slug index the config no longer declares. No-op on SQL.
disabledfalseRegister fields/indexes but skip hooks and backfill (keeps the schema stable for migrations).

Pagination

The /page/N scheme is the default PaginationStrategy, applied by the resolver only after an exact-path lookup misses. Configure or replace it per resolver via the pagination option:

import { pagePathPagination } from '@whatworks/payload-paths/next'

createPathResolver({
  collection: 'posts',
  config: pathsConfig,
  getPayload,
  // Rename the keyword (`/blog/p/2`), cap the page number, and serve page 1
  // in place (no redirect to the canonical path):
  pagination: pagePathPagination({ segment: 'p', maxPageNumber: 500, redirectFirstPage: false }),
})
  • pagination: false — disable pagination entirely; a /page/N URL then only resolves if a real document is stored at that literal path.
  • Custom scheme — pass any PaginationStrategy, i.e. { parse: (segments) => PaginatedSlugSegments }. Return { documentSegments: segments } (no pageNumber) to decline and let the resolver report not-found; return pageNumber, or redirectToDocumentPath: true, or invalidPage: true to shape the outcome.

For building pagination links, the default scheme is exposed as pure functions: getPathnameWithPageNumber('/guides', 3) → /guides/page/3, and getPathnameWithoutPageNumber('/guides/page/3') → /guides. (parsePaginatedSlugSegments is the same default frozen as a standalone parser.) These use the built-in /page/N scheme regardless of a custom strategy — mirror your own scheme in link building if you change it.

Multiple collections on one route

To serve more than one collection from a single route — e.g. pages and posts both under [[...slug]] — compose them with the multi-collection helpers. The resolved collection tells the page which renderer to use.

// app/(frontend)/[[...slug]]/page.tsx
import {
  createMultiGenerateStaticParams,
  createMultiPathResolver,
} from '@whatworks/payload-paths/next'

const resolvePage = createMultiPathResolver({
  collections: ['pages', 'posts'], // priority order (see below)
  config: pathsConfig,
  getPayload: getPayloadInstance,
})

export const generateStaticParams = createMultiGenerateStaticParams({
  collections: ['pages', 'posts'],
  config: pathsConfig,
  getPayload: getPayloadInstance,
})

export default async function Page({ params }: PageProps<'/[[...slug]]'>) {
  const { collection, doc } = await resolvePage({ params })
  return collection === 'posts' ? <Post doc={doc} /> : <PageView doc={doc} />
}

Ordering rules (implemented by createResolverChain, which both helpers use and which you can call directly for non-Next apps):

  • By segments (a catch-all param): resolvers are tried in the given order, first found/redirect wins. List the higher-priority collection first.
  • By pathname: only resolvers whose prefix the pathname falls under are tried, most-specific (longest) prefix first — so /blog/hello resolves against the /blog collection before a root collection, the way a router ranks a specific route above a catch-all.

createResolverChain returns a PathsResolver itself, so chains compose: listPaths is the deduped union of the children's paths, and prefix is their shared prefix (or '' when they differ).

Prefixed collection on its own route

Alternatively, give a prefixed collection a dedicated folder and let the folder eat the prefix — stored paths and route params stay prefix-free:

// app/(frontend)/blog/[[...slug]]/page.tsx
const resolvePosts = createPathResolver({ collection: 'posts', config: pathsConfig, getPayload })

Static params

createGenerateStaticParams (and the multi-collection variant) is driven by the stored paths; emitted segments never include the prefix — the route folder supplies it.

OptionDefaultPurpose
limitallCap the number of paths prerendered; 0 disables prerendering (everything renders on demand).
paramName'slug'The bracket folder name — 'path' for [[...path]]. Must match the folder and the resolver.
routeType'optional-catch-all''optional-catch-all' ([[...slug]], root emitted as []), 'catch-all' ([...slug]), 'dynamic' ([slug]).
scopeall (deduped)Prerender one tenant's paths for a scoped collection.
where—Extra filter AND-merged with the built-in constraints (published, non-null path, scope).

where can only narrow the set — drafts and null-path documents are never prerendered — so { hideFromPrerender: { not_equals: true } } excludes flagged pages from the build while they still render on demand under dynamicParams.

Caching

Lookups are single indexed queries, so caching is optional. Three adapters ship, plus the PathsCache contract (wrap + invalidate) if you want your own:

  • noopPathsCache() (core default) — no caching, always correct, works anywhere.
  • memoryPathsCache({ maxEntries, ttlMs }) — in-process, tag-invalidated. Correct only when the writer (hooks) and reader (routes) share one long-lived process and the same instance — tests, a single self-hosted server.
  • nextPathsCache() (@whatworks/payload-paths/next/plugin, also re-exported from /next) — backed by unstable_cache/revalidateTag; the default for nextPathsPlugin and createPathResolver. Works on Next 15 & 16, self-hosted and on Vercel.

The plugin invalidates by tag on every path change; the Next plugin additionally revalidatePaths the affected URLs (full-route/ISR cache) via the built-in revalidatePathsOnChange handler.

Edit button

A floating, draggable edit button for your frontend: it deep-links the page you are looking at straight to its document in the admin — because this package owns the pathname → document mapping, it works on every page with zero per-page wiring. Anonymous visitors never see it and (unlike a /me-polling admin bar) never trigger a request for it.

The collapsed edit button: a small dark pill with a pencil icon and a green published-status dot, pinned to the bottom-right corner of a frontend page The expanded edit button: an “Edit page” pill with its menu open, showing the document title, its collection and URL, a Published status with last-updated time, Edit page / Versions / API actions, a Parents trail, Dashboard, Account, Log out, and a corner picker

1. Enable the server half on the plugin (an authenticated GET endpoint + an admin provider):

nextPathsPlugin({ ...pathsConfig, editButton: true }) // or pathsPlugin({ ... })

Then regenerate your import map (payload generate:importmap) so the admin provider resolves.

2. Mount the component once, in your frontend root layout:

// Next.js — draft-mode aware (server wrapper):
import { NextPathsEditButton } from '@whatworks/payload-paths/next'
;<NextPathsEditButton exitPreviewURL="/exit-preview" />

// Any other React frontend (framework-agnostic client component):
import { PathsEditButton } from '@whatworks/payload-paths/client'
;<PathsEditButton />
// app/exit-preview/route.ts — ends draft-mode sessions (Next only)
import { createExitPreviewRoute } from '@whatworks/payload-paths/next/exit-preview'
export const GET = createExitPreviewRoute()

What you get: a corner-pinned dot that expands on hover into Edit page (the primary action — one click into the exact admin document), plus a menu with the document's status (Published / Changed / Draft, drafts-enabled collections only), last-updated time, an ancestor trail (edit any parent in the tree), Live preview / Versions / API views (when configured), Dashboard, Account, Exit preview (draft mode), and Log out. Drag the dot to any viewport corner — the choice persists per browser, like Next's dev indicator.

Visibility & request budget

The button must know "is this visitor an editor?" without costing the public anything. Three layers keep it frugal:

  1. The editor hint. The plugin registers a tiny admin provider that stamps localStorage on every admin visit (the admin and frontend share an origin in the standard monolith). Browsers without the stamp — every anonymous visitor — make zero requests. A fresh browser shows the button after its first admin login (or immediately in draft mode).
  2. One endpoint, both answers. Hinted browsers call the plugin's endpoint (GET /api/paths/edit-button?pathname=…), which 401s for non-editors (the hint is cleared and a session-scoped verdict prevents re-asking) and otherwise returns the resolved document, its status/ancestors, and ready-made admin URLs. Confirmed editors pay about one request per new path per session — responses are memoized per pathname.
  3. Draft mode skips the gate. NextPathsEditButton reads draftMode() (static-safe) and passes it down; preview sessions always check and resolve newest-version content.

The endpoint only answers users of the admin auth collection (config.admin.user) — logged-in members of public-facing auth collections (customers, members) get a 403 and never learn draft state. Override with editButton: { access: ({ req }) => … } (it replaces the collection check; a non-null user is always required). Other options: endpointPath (default /paths/edit-button) and adminHint: false to skip the admin provider (the button then checks once per browser session instead).

Component props

All of usePathsEditButton's options plus the shell's: scope (multi-tenant), serverURL + apiRoute (cross-origin Payload — requires CORS and cookie config), draft, initial (seed a server-resolved context and skip the first fetch), defaultCorner, fallback: 'hide' | 'dashboard' (what to render on pages that resolve to no document), exitPreviewURL / onExitPreview, showInIframe (default false, so it stays out of the admin's live-preview pane), respectReducedMotion (default true — honours prefers-reduced-motion; set false to animate regardless), and zIndex. Prefer your own UI? usePathsEditButton() is exported from /client — it returns the resolved context, pathname, and status, and handles all the gating above.

The component is plain React with injected styles (no @payloadcms/ui, no CSS import): it tracks SPA navigations by listening to popstate and patching history.pushState/replaceState once (the standard toolbar technique), renders nothing until auth is confirmed (no flash, no hydration mismatch), and works in any React frontend that can reach Payload's REST API.

Framework-agnostic usage

Outside Next.js, use pathsPlugin from the core entry and createPathsResolver from @whatworks/payload-paths/resolver. No next import; not-found and redirect come back as values you map onto your own framework's primitives:

import { createPathsResolver } from '@whatworks/payload-paths/resolver'

const resolver = createPathsResolver({ collection: 'pages', config: pathsConfig, getPayload })

const result = await resolver.resolve({ pathname: url.pathname })
switch (result.type) {
  case 'found':
    return render(result.doc, { pageNumber: result.pageNumber, url: result.url })
  case 'redirect':
    return Response.redirect(result.redirectTo, 308)
  case 'not-found':
    return new Response('Not found', { status: 404 })
}

resolve accepts either segments (from a catch-all param) or a full pathname, plus optional draft and scope. listPaths({ limit?, scope?, where? }) returns the collection's stored paths for your own static-generation step.

Adoption & migration

Moving a collection off unique-slug + request-time path building:

  1. Add the plugin. Leave your slug field in place; drop its unique: true, keep index: true.
  2. Check readiness with checkPathsAdoption (below) — resolve any missing slugs and collisions it reports first.
  3. Boot once. The onInit pass drops the stale unique slug index (Mongo), builds the new path indexes, and backfills every existing document's path. On SQL, generate/run the Drizzle migration for the dropped constraint as usual.
  4. Swap the request path. Replace your ensureDocumentExists/getPath logic with createPathResolver.

Readiness check

checkPathsAdoption(payload, { legacyUrlFor }) does one read-only pass and reports, per collection: documents with no slug (unroutable), documents whose path is still null (need backfilling), published path collisions, stale unique indexes the plugin will drop, and — when you supply legacyUrlFor — every document whose public URL changes under the new scheme, so you can create redirects before old links break (the backfill writes paths silently and fires no onPathChanged).

// scripts/check-paths.ts — run with `payload run`
import { checkPathsAdoption } from '@whatworks/payload-paths'

const report = await checkPathsAdoption(payload, {
  legacyUrlFor: (doc) => oldUrlForDoc(doc), // omit to skip URL-change detection
})
if (!report.ok) process.exit(1)

findPathCollisions(payload) returns just the collision groups if that's all you need.

Integrity & backfill

  • backfillPaths(payload, { mode, limit, collections }) — repair null paths from a script (mode: 'check' to only count). Writes go through the database adapter, so no hooks, versions, or revalidation storms fire.
  • verifyPathIntegrity(payload, { fix }) — recompute every path and report (or repair) drift from bypassed hooks, direct DB edits, or failed cascades. Handy in CI or after a bulk import.
  • reconcileSlugIndexes / findStaleSlugUniqueIndexes — the Mongo index reconciliation the plugin runs on boot, exposed for manual use.

Helpers

From the core entry:

  • getDocPath(doc) / getDocUrl(doc, { prefix }) — typed accessors for a document's stored path/URL; they throw if path wasn't selected (a loud error instead of a silently-wrong URL).
  • createNestedDocsGenerateURL({ homeSlug, prefix }) — a generateURL for nested-docs that mirrors this package's path semantics, so admin breadcrumb URLs match the real stored paths.
  • Path primitives: composeUrl, stripPrefix, normalizePrefix, segmentsToPath, pathToSegments, appendSegment, collectionTag, pathTag.

Multi-tenant

Set scopeField on a collection (typically a tenant relationship). Uniqueness is enforced per scope, so two tenants can both own /about; the resolver then requires a scope argument to disambiguate, and listPaths/generateStaticParams accept a scope (or return every tenant's paths deduped).

License

MIT

Similar plugins