25 KiB
| title | label | order | desc | keywords | source |
|---|---|---|---|---|---|
| Hierarchy | Overview | 10 | Add hierarchical tree structure to any collection with automatic path generation and efficient querying. | hierarchy, tree structure, nested documents, breadcrumbs, parent-child | https://payloadcms.com/docs/hierarchy/overview |
The Hierarchy feature provides automatic tree structure management for any Payload collection. When enabled, it maintains parent-child relationships, generates breadcrumb paths, and enables efficient descendant queries.
Use it for: Nested pages, categories, organizational structures, folder systems, or any hierarchical data.
Hierarchy is a collection-level feature that automatically adds internal fields and hooks to manage tree structure without requiring custom code.Quick Start
Enable hierarchy on any collection by adding the hierarchy property:
import type { CollectionConfig } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
// highlight-start
hierarchy: {
parentFieldName: 'parent',
},
// highlight-end
}
This automatically:
- Creates a
parentrelationship field (if it doesn't exist) - Adds virtual
_h_slugPathand_h_titlePathfields (computed on-demand) - Sets up hooks to validate circular references and clean up tree on deletion
- Computes breadcrumb paths based on
admin.useAsTitlewhen requested
Auto-Generated Fields
When hierarchy is enabled, virtual path fields are automatically added to your collection. These fields are computed on-demand when requested and are not stored in the database.
_h_slugPath
Type: String or Localized Object (virtual field) Purpose: Slugified breadcrumb path for URLs and search Stored: No - computed on-demand from ancestor tree Read-only: Yes Requires: Opt-in (see Requesting Path Computation)
{
_h_slugPath: 'grandparent/parent/current',
// or localized:
_h_slugPath: {
en: 'store/products/widgets',
fr: 'magasin/produits/widgets'
}
}
Use for URLs:
const page = await payload.findByID({
collection: 'pages',
id: 'page-id',
context: { computeHierarchyPaths: true }, // Request path computation
})
// Use in your frontend routing
const url = `/${page._h_slugPath}` // "/store/products/widgets"
_h_titlePath
Type: String or Localized Object (virtual field) Purpose: Human-readable breadcrumb path for display Stored: No - computed on-demand from ancestor tree Read-only: Yes Requires: Opt-in (see Requesting Path Computation)
{
_h_titlePath: 'Grandparent/Parent/Current',
// or localized:
_h_titlePath: {
en: 'Store/Products/Widgets',
fr: 'Magasin/Produits/Widgets'
}
}
Use for breadcrumbs:
const page = await payload.findByID({
collection: 'pages',
id: 'page-id',
context: { computeHierarchyPaths: true },
})
const breadcrumbs = page._h_titlePath.split('/')
// ['Store', 'Products', 'Widgets']
Requesting Path Computation
Path fields (_h_slugPath and _h_titlePath) are virtual fields that must be explicitly requested. This opt-in design prevents unnecessary database queries when paths aren't needed (e.g., when loading documents through relationships).
Method 1: Context Flag
Pass computeHierarchyPaths: true in the context:
// Single document
const page = await payload.findByID({
collection: 'pages',
id: 'page-id',
context: { computeHierarchyPaths: true },
})
// Query multiple documents
const pages = await payload.find({
collection: 'pages',
where: { parent: { equals: null } },
context: { computeHierarchyPaths: true },
})
Method 2: Query Parameter
For REST API requests, add the computeHierarchyPaths query parameter:
# Get single document with paths
GET /api/pages/abc123?computeHierarchyPaths=true
# Query collection with paths
GET /api/pages?computeHierarchyPaths=true&where[parent][equals]=null
Method 3: Field Selection
Paths are automatically computed when you explicitly select them:
const page = await payload.findByID({
collection: 'pages',
id: 'page-id',
select: {
title: true,
_h_slugPath: true,
_h_titlePath: true,
},
})
// REST API
// GET /api/pages/abc123?select[title]=true&select[_h_slugPath]=true
Performance Considerations
Query Cost: Computing paths requires one additional query per document to fetch all ancestors. For example, loading 50 folder documents will make 51 queries (1 for the folders, 1 for ancestors).
Request-Scoped Caching: Ancestors are cached within each request, so if multiple documents share the same parent, the parent is only fetched once:
// Load 50 folders with same parent
const folders = await payload.find({
collection: 'folders',
where: { parent: { equals: 'parent-id' } },
context: { computeHierarchyPaths: true },
})
// Result: Only 2 queries total (1 for folders, 1 for the shared parent)
Recommendation: Only request paths when you need them for URLs or breadcrumbs. Skip path computation when loading related documents if paths aren't used.
Configuration
Basic Configuration
Enable with defaults (parent field auto-created):
{
slug: 'pages',
fields: [
{
name: 'title',
type: 'text',
required: true,
}
],
hierarchy: {
parentFieldName: 'parent', // Field will be auto-created
}
}
With Custom Parent Field
Define the parent field yourself for custom validation or UI:
{
slug: 'pages',
fields: [
{
name: 'parentPage',
type: 'relationship',
relationTo: 'pages', // Must be self-referential
hasMany: false, // Must be single relationship
admin: {
description: 'Select a parent page',
position: 'sidebar',
},
validate: (value, { id }) => {
if (value === id) {
return 'Document cannot be its own parent'
}
return true
},
},
{
name: 'title',
type: 'text',
}
],
hierarchy: {
parentFieldName: 'parentPage', // References your custom field
}
}
**Important:** If you define the parent field manually, it must be: - `type:
'relationship'` - `relationTo: current-collection-slug` (self-referential) -
`hasMany: false`
With Custom Options
{
slug: 'pages',
fields: [
{
name: 'title',
type: 'text',
}
],
hierarchy: {
parentFieldName: 'parent',
// Optional: Custom slugify function
slugify: (text) => {
return text.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
},
// Optional: Custom field names
slugPathFieldName: '_breadcrumbPath',
titlePathFieldName: '_breadcrumbTitle',
}
}
Config Options
| Option | Type | Required | Description |
|---|---|---|---|
parentFieldName |
string |
Yes | Name of the parent relationship field. Will be auto-created if it doesn't exist. |
relationTo |
string | string[] |
No | Collection(s) that can be used as parents. Single string for monomorphic (same collection), array for polymorphic (multiple collections). Defaults to self-referential. |
slugify |
function |
No | Custom function to slugify text for path generation. Default uses basic slugify. |
slugPathFieldName |
string |
No | Name for the virtual slugified path field. Default: '_h_slugPath' |
titlePathFieldName |
string |
No | Name for the virtual title path field. Default: '_h_titlePath' |
Polymorphic Hierarchies
Polymorphic hierarchies allow documents to have parents from different collections, enabling flexible content organization like blog posts under pages, products under categories, or mixed content trees.Basic Example
import type { CollectionConfig } from 'payload'
// Pages collection - self-referential hierarchy
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
hierarchy: {
parentFieldName: 'parent',
// No relationTo specified = self-referential (pages under pages only)
},
}
// Posts collection - polymorphic hierarchy
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
// highlight-start
hierarchy: {
parentFieldName: 'parent',
relationTo: ['pages', 'posts'], // Posts can nest under pages OR other posts
},
// highlight-end
}
This enables organizing content like:
Pages Hierarchy:
- Home (page)
└─ About (page)
└─ Blog (page)
├─ First Post (post under page)
│ └─ Reply (post under post)
└─ Second Post (post under page)
Creating Documents with Polymorphic Parents
When creating a document with a polymorphic parent, specify both the collection and ID:
// Create a page
const blogPage = await payload.create({
collection: 'pages',
data: {
title: 'Blog',
parent: null,
},
})
// Create a post under the page (cross-collection parent)
const post = await payload.create({
collection: 'posts',
data: {
title: 'First Post',
parent: {
relationTo: 'pages', // Parent is a page
value: blogPage.id,
},
},
})
// Create a reply post under the post (same-collection parent)
const reply = await payload.create({
collection: 'posts',
data: {
title: 'Reply',
parent: {
relationTo: 'posts', // Parent is another post
value: post.id,
},
},
})
Path Computation
Paths are computed across collections automatically:
// Get post with computed paths
const post = await payload.findByID({
collection: 'posts',
id: post.id,
context: { computeHierarchyPaths: true },
})
// post._h_slugPath: 'blog/first-post'
// post._h_titlePath: 'Blog/First Post'
// Get reply with nested path
const reply = await payload.findByID({
collection: 'posts',
id: reply.id,
context: { computeHierarchyPaths: true },
})
// reply._h_slugPath: 'blog/first-post/reply'
// reply._h_titlePath: 'Blog/First Post/Reply'
Use Cases
Blog Posts Under Pages
export const Pages: CollectionConfig = {
slug: 'pages',
hierarchy: {
parentFieldName: 'parent',
// Self-referential only
},
}
export const Posts: CollectionConfig = {
slug: 'posts',
hierarchy: {
parentFieldName: 'parent',
relationTo: ['pages', 'posts'], // Posts can go under pages or nest under other posts
},
}
Result: Blog section can be a page with posts nested underneath, posts can have threaded replies.
Products Under Categories
export const Categories: CollectionConfig = {
slug: 'categories',
hierarchy: {
parentFieldName: 'parent',
// Self-referential only
},
}
export const Products: CollectionConfig = {
slug: 'products',
hierarchy: {
parentFieldName: 'parent',
relationTo: ['categories', 'products'], // Products under categories, or product variants under products
},
}
Result: Categories form the main tree, products nest within categories, product variants can nest under products.
Mixed Content Tree
export const Pages: CollectionConfig = {
slug: 'pages',
hierarchy: {
parentFieldName: 'parent',
},
}
export const Folders: CollectionConfig = {
slug: 'folders',
hierarchy: {
parentFieldName: 'parent',
relationTo: ['pages', 'folders'], // Folders under pages or folders
},
}
export const Documents: CollectionConfig = {
slug: 'documents',
hierarchy: {
parentFieldName: 'parent',
relationTo: ['pages', 'folders'], // Documents under pages or folders
},
}
Result: Flexible organization where pages are top-level, folders and documents can nest under pages or folders.
Important Behaviors
Circular Reference Protection
Circular reference detection works across collections:
// This will fail:
// Page A -> Post B -> Page A (circular)
const pageA = await payload.create({
collection: 'pages',
data: { title: 'Page A', parent: null },
})
const postB = await payload.create({
collection: 'posts',
data: {
title: 'Post B',
parent: { relationTo: 'pages', value: pageA.id },
},
})
// ❌ This throws an error
await payload.update({
collection: 'pages',
id: pageA.id,
data: { parent: { relationTo: 'posts', value: postB.id } },
})
// Error: Circular reference detected
Draft and Localization
Polymorphic hierarchies fully support drafts and localization:
- Drafts: Path computation follows draft context across collections
- Localization: Paths computed per locale using each collection's localized title fields
Target Collection Requirements
When using relationTo with multiple collections:
- Target collections should also have hierarchy enabled
- Target collections must exist in the config
- If a target collection doesn't have hierarchy enabled, you'll see a warning (but it will still work for simple parent relationships)
Limitations
Parent Field Cannot Be Localized
The parent field stores the relationship and must be consistent across all locales. This means:
- ✅ Tree structure is the same across all locales
- ✅ Path titles can differ per locale
- ❌ Cannot have different parent relationships per locale
Manual Parent Field
If you manually define the parent field for a polymorphic hierarchy:
export const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{
name: 'parent',
type: 'relationship',
relationTo: ['pages', 'posts'], // Must match hierarchy config
hasMany: false,
admin: {
position: 'sidebar',
},
},
{
name: 'title',
type: 'text',
},
],
hierarchy: {
parentFieldName: 'parent',
relationTo: ['pages', 'posts'], // Must match field config
},
}
**Important:** The `relationTo` in your manual field must exactly match the
`relationTo` in the hierarchy config, otherwise validation will fail.
Use Cases
Nested Pages
{
slug: 'pages',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'content',
type: 'richText',
},
],
hierarchy: {
parentFieldName: 'parent',
}
}
Nested Categories
{
slug: 'categories',
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
],
hierarchy: {
parentFieldName: 'parentCategory',
}
}
Organizational Structure
{
slug: 'departments',
admin: {
useAsTitle: 'deptName',
},
fields: [
{
name: 'deptName',
type: 'text',
required: true,
},
],
hierarchy: {
parentFieldName: 'parentDept',
slugPathFieldName: '_orgPath',
titlePathFieldName: '_orgBreadcrumb',
}
}
Localization Support
If the title field (from admin.useAsTitle) is localized, path fields are automatically localized:
{
slug: 'pages',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true, // Enables localized paths
},
],
hierarchy: {
parentFieldName: 'parent',
}
}
Result:
{
title: {
en: 'Products',
fr: 'Produits',
},
_h_slugPath: {
en: 'store/products',
fr: 'magasin/produits',
},
_h_titlePath: {
en: 'Store/Products',
fr: 'Magasin/Produits',
}
}
How It Works
Parent Relationship Management
Hierarchy maintains parent-child relationships through a simple parent field:
- Validates there are no circular references when parent changes
- Cleans up orphaned children when a parent is deleted (sets their parent to null)
- No cascade updates needed - paths are always computed fresh
Example:
// Move "Page C" from under "Page A" to under "Page B"
await payload.update({
collection: 'pages',
id: 'page-c',
data: { parent: 'page-b' }, // Changed from page-a
})
// Only updates:
// - page-c's parent field: 'page-a' → 'page-b'
// - No descendant updates needed
// - Paths computed fresh on next read
Path Computation
Path fields are computed on-demand when requested:
- Walk Parent Chain: Recursively follows
parentrelationships to root - Build Paths: Concatenates titles/slugs from ancestors in correct order
- Cache Results: Ancestors cached in
req.contextfor the request duration - Localization: Paths computed per locale if title field is localized
Title Change Behavior:
When you change a document's title, paths are automatically updated on the next read—no cascade updates needed:
// Change a parent's title from "Products" to "Items"
await payload.update({
collection: 'pages',
id: 'parent-id',
data: { title: 'Items' },
})
// Next time a child is read with path computation:
const child = await payload.findByID({
collection: 'pages',
id: 'child-id',
context: { computeHierarchyPaths: true },
})
// child._h_titlePath reflects the new parent title:
// Old: "Products/Widget"
// New: "Items/Widget"
No descendants are updated in the database—paths always reflect current ancestor titles.
Important Behaviors
No Cascade Updates
When you move or rename a document, descendants are not updated:
- ✅ Only parent field updated - The document's
parentfield is changed - ✅ No descendant updates - Children and descendants are not touched
- ✅ Paths always accurate - Paths computed fresh on read always reflect current hierarchy
Performance Benefit: Moving or renaming documents is fast regardless of how many descendants exist.
Example:
// Moving a folder with 50 descendant pages
await payload.update({
collection: 'pages',
id: 'folder-id',
data: { parent: 'new-parent' },
})
// Result:
// - Folder: ✅ parent field updated
// - 50 descendants: No updates (still point to folder-id)
// - Paths: Computed fresh on next read, automatically reflect new structure
Circular Reference Protection
Hierarchy automatically prevents circular references. You cannot:
- Set a document as its own parent
- Set a descendant as a parent (e.g., grandchild → parent → grandchild)
These operations will throw an error before any changes are made.
Draft Version Handling
When versioning with drafts is enabled, paths are computed based on the current read context:
- ✅ Draft context - Paths computed using draft parent and draft title
- ✅ Published context - Paths computed using published parent and published title
- ✅ Always accurate - Paths always reflect the correct version's data
Example:
// Reading published version
const published = await payload.findByID({
collection: 'pages',
id: 'page-id',
context: { computeHierarchyPaths: true },
// draft: false (default)
})
// published._h_slugPath: 'products/clothing' (uses published parent title)
// Reading draft version
const draft = await payload.findByID({
collection: 'pages',
id: 'page-id',
draft: true,
context: { computeHierarchyPaths: true },
})
// draft._h_slugPath: 'products/apparel' (uses draft title if changed)
How it works:
- When computing paths, hierarchy fetches ancestors using the same
draftcontext - If reading a draft, ancestor titles come from draft versions (if they exist)
- If reading published, ancestor titles come from published versions
- This ensures paths always reflect the correct version's hierarchy state
No Cascade Updates Needed:
Unlike stored paths, computed paths don't require updating draft versions when a parent changes. Paths are always accurate because they're computed from the current version's tree structure.
Limitations
Locale 'all' Not Supported
Updates with locale: 'all' will skip hierarchy processing. Workaround: Update each locale individually.
Parent Changes with publishSpecificLocale
Use with caution: When using publishSpecificLocale, be aware that the parent field is not localized—tree structure must be consistent across locales.
When you publish a draft with a changed parent using publishSpecificLocale:
- The parent change applies to all locales (parent field is not localized)
- Paths are computed per locale on next read
- Each locale's paths will reflect the new parent combined with that locale's titles
Example:
// Published document with localized titles
{
parent: 'parent-1',
title: { en: 'Page', fr: 'Page' },
_h_slugPath: { en: 'products/page', fr: 'produits/page' }
}
// Draft changes parent
await payload.update({
collection: 'pages',∫
id: 'doc-id',
data: { parent: 'parent-2' },
draft: true,
})
// Publish only French
await payload.update({
collection: 'pages',
id: 'doc-id',
publishSpecificLocale: 'fr',
})
// Result: parent changed to 'parent-2' for ALL locales
// Paths computed on next read will reflect new parent for all locales
Recommendation: When moving documents in the hierarchy (changing parent), prefer publishAllLocales (default) to make the intent clear:
// ✅ Clear: publishes parent change for all locales
await payload.update({
collection: 'pages',
id: 'doc-id',
data: { parent: 'new-parent' },
// publishAllLocales is default
})
Note: Title changes work as expected with publishSpecificLocale since paths are computed per locale.
Best Practices
Use Consistent Title Fields
Ensure your admin.useAsTitle field is stable and always has a value:
{
admin: {
useAsTitle: 'title', // ✅ Clear, user-facing field
},
fields: [
{
name: 'title',
type: 'text',
required: true, // ✅ Always has a value
}
]
}
Avoid: Fields that can be empty, computed fields, or fields nested in named groups/tabs.
Consider Performance at Scale
For collections with many documents:
- Only request paths when needed - Use
computeHierarchyPaths: trueonly for UI/breadcrumb display - Skip paths for relationships - When loading related documents, omit path computation to avoid extra queries
- Limit tree depth - Deep trees (10+ levels) will have slower path computation
- Leverage caching - Multiple documents sharing ancestors benefit from request-scoped caching
Example optimization:
// ❌ Unnecessary path computation for a tag relationship
const post = await payload.findByID({
collection: 'posts',
id: 'post-id',
depth: 2, // Populates category relationship
context: { computeHierarchyPaths: true }, // Computes paths for category too
})
// ✅ Only compute paths when you'll use them
const post = await payload.findByID({
collection: 'posts',
id: 'post-id',
depth: 2,
// No path computation - category.parent populated but no _h_slugPath
})
TypeScript
The hierarchy fields are automatically included in your generated types:
import type { Page } from './payload-types'
// Fetched document type includes all hierarchy fields
const page = await payload.findByID({
collection: 'pages',
id: 'page-id',
context: { computeHierarchyPaths: true },
})
// TypeScript knows about these fields:
const id: string = page.id
const title: string = page.title
const parent: string | null = page.parent
const slugPath: string = page._h_slugPath // Virtual field, computed when requested
const titlePath: string = page._h_titlePath // Virtual field, computed when requested
Note: Virtual path fields (_h_slugPath, _h_titlePath) are included in generated types but will be undefined at runtime unless you request path computation.
If types aren't generated, regenerate them:
payload generate:types