🚧 Under development — suggestions for new items are always welcome! ✨

Path Field

Store a document’s full, nested path and fetch it document by 1 property—no nested folders or knowing collection. Just call findDocumentByPath('/about/team') and render.

What it does

  • Guarantees unique paths across the collections you opt‑in. 1 path = 1 document.
  • Makes routing trivial with two helper functions.
  • Only docs with a path are routable; data‑only collections (e.g. form submissions) stay internal.

Install

pnpm dlx shadcn@latest add https://p.livog.com/r/path-field.json

Code

import { generateBreadcrumbsUrl } from '@/payload/utils/generate-breadcrumbs-url'
import { getParents } from '@payloadcms/plugin-nested-docs'
import type { CollectionSlug, Payload, PayloadRequest, TextField, Where } from 'payload'
import { APIError, ValidationError } from 'payload'

const generateRandomString = (length = 20): string => {
  return Array.from({ length }, () => Math.floor(Math.random() * 36).toString(36)).join('')
}

export type WillPathConflictParams = {
  payload: Payload
  path: string
  originalDoc?: { id?: string }
  collection: CollectionSlug
  uniquePathCollections?: CollectionSlug[] | ReadonlyArray<CollectionSlug>
}

export const willPathConflict = async ({
  payload,
  path,
  originalDoc,
  collection,
  uniquePathCollections = []
}: WillPathConflictParams): Promise<boolean> => {
  if (!payload || !uniquePathCollections.includes(collection)) return false

  const queries = uniquePathCollections.map((targetCollection) => {
    const whereCondition: Where = {
      path: { equals: path }
    }
    if (originalDoc?.id && collection === targetCollection) {
      whereCondition.id = { not_equals: originalDoc.id }
    }

    return payload.find({
      collection: targetCollection,
      where: whereCondition,
      limit: 1,
      pagination: false
    })
  })

  const results = await Promise.allSettled(queries)
  return results.some((result) => result.status === 'fulfilled' && (result as PromiseFulfilledResult<any>).value.docs.length > 0)
}

export type GenerateDocumentPathParams = {
  req: PayloadRequest
  collection: CollectionSlug
  currentDoc: any
  fieldToUse: string
  parentFieldSlug?: string
}

export async function generateDocumentPath({
  req,
  collection,
  currentDoc,
  fieldToUse,
  parentFieldSlug = 'parent'
}: GenerateDocumentPathParams): Promise<string> {
  if (!currentDoc?.[fieldToUse] || !collection) {
    return `/${currentDoc?.id || generateRandomString(20)}`
  }

  const breadcrumbs = currentDoc?.breadcrumbs
  const newPath = breadcrumbs?.at(-1)?.url
  if (newPath) return newPath

  const docs = await getParents(req, { parentFieldSlug } as any, { slug: collection } as any, currentDoc, [currentDoc])

  return generateBreadcrumbsUrl(docs, currentDoc)
}

type PathFieldOptions = {
  uniquePathCollections: CollectionSlug[] | ReadonlyArray<CollectionSlug>
  fieldToUse?: string
  parentFieldSlug?: string
  overrides?: Partial<TextField>
}

const pathField = ({
  uniquePathCollections,
  fieldToUse = 'slug',
  parentFieldSlug = 'parent',
  overrides
}: PathFieldOptions): TextField[] => {
  return [
    {
      name: '_collection',
      type: 'text',
      admin: {
        hidden: true
      },
      virtual: true,
      hooks: {
        beforeValidate: [({ collection }) => collection?.slug || null]
      }
    },
    (() => {
      const defaultHooks = {
        beforeDuplicate: [() => `/${generateRandomString(20)}`],
        beforeChange: [
          async ({ collection, data, req, siblingData, originalDoc }) => {
            if (!collection) {
              throw new APIError(
                'Collection is null.',
                400,
                [{ field: fieldToUse, message: 'Collection is required.' }],
                false
              )
            }

            const currentDoc = { ...originalDoc, ...siblingData }

            const newPath = await generateDocumentPath({
              req,
              collection: collection.slug as CollectionSlug,
              currentDoc,
              fieldToUse,
              parentFieldSlug
            })

            const isNewPathConflicting = await willPathConflict({
              payload: req.payload,
              path: newPath,
              originalDoc,
              collection: collection.slug as CollectionSlug,
              uniquePathCollections: uniquePathCollections
            })

            if (isNewPathConflicting) {
              throw new ValidationError({
                collection: collection.slug,
                req,
                errors: [{ path: fieldToUse, message: 'Path already in use.' }]
              })
            }
            if (data) data.path = newPath
            return newPath
          }
        ]
      }

      const { admin: overridesAdmin, hooks: overridesHooks, ...restOverrides } = overrides || {}

      const mergedHooks = {
        ...(overridesHooks || {}),
        beforeDuplicate: [...defaultHooks.beforeDuplicate, ...(overridesHooks?.beforeDuplicate || [])],
        beforeChange: [...defaultHooks.beforeChange, ...(overridesHooks?.beforeChange || [])]
      }

      return {
        type: 'text',
        name: 'path',
        unique: true,
        index: true,
        admin: { position: 'sidebar', readOnly: true, ...(overridesAdmin || {}) },
        hooks: mergedHooks,
        ...restOverrides
      } as TextField
    })()
  ]
}

export { pathField }

Usage

Step 1 — Add a parent relation field

Add the nested‑docs plugin in payload.config.ts:

plugins: [
  nestedDocsPlugin({
    collections: ['pages', 'posts'],
    generateURL: generateBreadcrumbsUrl,
  }),
]

or add the field directly inside each collection:

import { createParentField } from '@payloadcms/plugin-nested-docs'

fields: [
  createParentField('pages'), // this collection's slug
  // other fields…
]

Step 2 — Add the Path field to every public collection

// collections/pages.ts
...pathField({
  uniquePathCollections: ['pages', 'posts'],
  fieldToUse: 'slug',
}),

Next.js route (app/(frontend)/[[...path]]/page.tsx)

import { normalizePath } from 'payload/utilities'
import { findDocumentByPath } from '@/lib/payload/get-document'
import type { PageProps } from 'next'

const extractPath = async (params: PageProps['params']): Promise<string> => {
  const { path = '/' } = await params
  return normalizePath(path, false)
}

export default async function Page({ params }: PageProps) {
  const path = await extractPath(params)
  const document = await findDocumentByPath(path)

  if (!document) return notFound()

  // Render whatever layout you need
}

Helper functions

  • findDocumentByPath(path)– Detects which collection owns the path and returns the document plus ._collection.
  • getDocumentByPath(path, collectionSlug) – Same as above but skips detection when you already know the collection slug.

Conditional layouts per collection

Example of: app/(frontend)/[[...path]]/page.tsx

export default async function Page({ params }: PageProps) {
  const path = await extractPath(params)
  const document = await findDocumentByPath(path)

  if (!document) return notFound()

  if (document._collection === 'pages') return <PageContent document={document} />
  if (document._collection === 'posts') return <PostContent document={document} />

  return null
}

When to use

  • You need nested URLs like /about/team/press without creating folder routes for every collection.
  • You have multiple public collections (pages, posts …) that should share one URL namespace.
  • You prefer resolving any URL with a single helper call instead of slug + collection queries.
  • You want data collections (e.g. form submissions) to remain internal while content collections stay publicly accessible.