obsidian/wiki/payloadcms/hierarchy.md
2026-05-15 15:48:53 +01:00

7.2 KiB

title aliases tags sources created updated
Hierarchy — Tree Structure for Collections
payload-hierarchy
nested-documents
breadcrumb-paths
payloadcms
hierarchy
tree
breadcrumbs
parent-child
polymorphic
raw/hierarchy__overview.md
2026-05-15 2026-05-15

Overview

The Hierarchy feature adds automatic tree structure management to any Payload collection. Enable it with a single hierarchy property — Payload handles parent-child relationships, circular reference protection, and on-demand breadcrumb path generation.

Use for: Nested pages, category trees, org charts, folder systems, blog posts under pages.

Quick Start

export const Pages: CollectionConfig = {
  slug: 'pages',
  admin: { useAsTitle: 'title' },
  fields: [{ name: 'title', type: 'text', required: true }],
  hierarchy: {
    parentFieldName: 'parent', // auto-created if field doesn't exist
  },
}

Auto-generated by enabling hierarchy:

  • parent relationship field (self-referential)
  • Virtual _h_slugPath — slugified URL path, computed on-demand
  • Virtual _h_titlePath — human-readable breadcrumb path, computed on-demand
  • Hooks: circular reference validation, orphan cleanup on deletion

Auto-Generated Virtual Fields

Both fields are not stored in DB — computed by walking the parent chain on read.

Field Type Example value
_h_slugPath string or localized object 'store/products/widgets'
_h_titlePath string or localized object 'Store/Products/Widgets'

If admin.useAsTitle field is localized: true, both path fields are automatically localized too.

Requesting Path Computation

Paths are opt-in (prevents unnecessary DB queries on relationship loads).

Method 1 — Context flag (Local API):

const page = await payload.findByID({
  collection: 'pages',
  id: 'page-id',
  context: { computeHierarchyPaths: true },
})
// page._h_slugPath → 'store/products/widgets'

Method 2 — Query param (REST API):

GET /api/pages/abc123?computeHierarchyPaths=true

Method 3 — Explicit select:

const page = await payload.findByID({
  collection: 'pages',
  id: 'page-id',
  select: { title: true, _h_slugPath: true, _h_titlePath: true },
})

Performance

  • Each document needing paths = 1 extra query for ancestors
  • Request-scoped caching: shared ancestors fetched only once
  • 50 sibling docs → only 2 queries total (1 for docs, 1 for shared parent)
  • Avoid computeHierarchyPaths when populating relationships where paths aren't needed

Config Options

Option Type Required Description
parentFieldName string Yes Field name for parent relationship (auto-created if missing)
relationTo string | string[] No Collection(s) allowed as parents. Default: self-referential
slugify function No Custom slug function for path generation
slugPathFieldName string No Override virtual field name (default: _h_slugPath)
titlePathFieldName string No Override virtual field name (default: _h_titlePath)

Custom Parent Field

If you define the field manually, it must be:

  • type: 'relationship'
  • relationTo: 'same-collection-slug' (self-referential)
  • hasMany: false

The relationTo in the manual field and in hierarchy config must match exactly.

Polymorphic Hierarchies

Allow documents to have parents from different collections:

// Posts can nest under pages OR other posts
export const Posts: CollectionConfig = {
  slug: 'posts',
  hierarchy: {
    parentFieldName: 'parent',
    relationTo: ['pages', 'posts'],
  },
}

Creating cross-collection parent:

const post = await payload.create({
  collection: 'posts',
  data: {
    title: 'First Post',
    parent: { relationTo: 'pages', value: blogPage.id },
  },
})

Common polymorphic patterns:

  • Blog posts under pages, threaded replies under posts
  • Products under categories, variants under products
  • Folders + documents nested under pages or other folders

Important Behaviors

No Cascade Updates

Moving or renaming a document does not update descendants — only the parent field is changed. Paths always reflect current hierarchy because they're computed fresh on read.

// Move folder with 50 descendants — still fast, no cascade
await payload.update({ collection: 'pages', id: 'folder-id', data: { parent: 'new-parent' } })
// Descendants: unchanged. Paths: accurate on next read.

Circular Reference Protection

Automatic detection across collections. Setting a descendant as a parent throws before any DB change.

Draft Support

Path computation respects the draft context:

  • Reading draft → ancestor titles from draft versions
  • Reading published → ancestor titles from published versions

Locale Limitations

  • locale: 'all' skips hierarchy processing — update each locale individually
  • parent field is not localized: tree structure is the same across all locales
  • publishSpecificLocale changes parent for all locales (since parent is not localized)

Use Cases

// Nested pages
hierarchy: { parentFieldName: 'parent' }

// Nested categories
hierarchy: { parentFieldName: 'parentCategory' }

// Org chart with custom path field names
hierarchy: {
  parentFieldName: 'parentDept',
  slugPathFieldName: '_orgPath',
  titlePathFieldName: '_orgBreadcrumb',
}

TypeScript

Virtual fields are included in generated types but are undefined unless paths are requested:

const page = await payload.findByID({
  collection: 'pages',
  id: 'page-id',
  context: { computeHierarchyPaths: true },
})
const url: string = `/${page._h_slugPath}`      // 'store/products/widget'
const crumbs = page._h_titlePath.split('/')      // ['Store', 'Products', 'Widget']

Regenerate types after adding hierarchy:

payload generate:types

Key Takeaways

  • Enable hierarchy with one config property — no custom code needed
  • Virtual path fields (_h_slugPath, _h_titlePath) are computed on-demand, not stored
  • Always opt-in with computeHierarchyPaths: true — avoids unnecessary queries on relationship loads
  • No cascade updates: moving/renaming is O(1) regardless of descendants
  • Polymorphic: relationTo: ['pages', 'posts'] lets documents parent across collections
  • Circular references caught automatically before any DB change
  • parent field is never localized — tree structure is global, only path labels vary per locale
  • Deep trees (10+ levels) slow down path computation — keep trees shallow when possible

Sources