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.