Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

Hierarchy

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.

Quick Start

Enable hierarchy on any collection by adding the hierarchy property:

1
import type { CollectionConfig } from 'payload'
2
3
export const Pages: CollectionConfig = {
4
slug: 'pages',
5
admin: {
6
useAsTitle: 'title',
7
},
8
fields: [
9
{
10
name: 'title',
11
type: 'text',
12
required: true,
13
},
14
],
15
hierarchy: {
16
parentFieldName: 'parent',
17
},
18
}

This automatically:

  • Creates a parent relationship field (if it doesn't exist)
  • Adds virtual _h_slugPath and _h_titlePath fields (computed on-demand)
  • Sets up hooks to validate circular references and clean up tree on deletion
  • Computes breadcrumb paths based on admin.useAsTitle when 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)

1
{
2
_h_slugPath: 'grandparent/parent/current',
3
// or localized:
4
_h_slugPath: {
5
en: 'store/products/widgets',
6
fr: 'magasin/produits/widgets'
7
}
8
}

Use for URLs:

1
const page = await payload.findByID({
2
collection: 'pages',
3
overrideAccess: true,
4
id: 'page-id',
5
context: { computeHierarchyPaths: true }, // Request path computation
6
})
7
8
// Use in your frontend routing
9
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)

1
{
2
_h_titlePath: 'Grandparent/Parent/Current',
3
// or localized:
4
_h_titlePath: {
5
en: 'Store/Products/Widgets',
6
fr: 'Magasin/Produits/Widgets'
7
}
8
}

Use for breadcrumbs:

1
const page = await payload.findByID({
2
collection: 'pages',
3
overrideAccess: true,
4
id: 'page-id',
5
context: { computeHierarchyPaths: true },
6
})
7
8
const breadcrumbs = page._h_titlePath.split('/')
9
// ['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:

1
// Single document
2
const page = await payload.findByID({
3
collection: 'pages',
4
overrideAccess: true,
5
id: 'page-id',
6
context: { computeHierarchyPaths: true },
7
})
8
9
// Query multiple documents
10
const pages = await payload.find({
11
collection: 'pages',
12
overrideAccess: true,
13
where: { parent: { equals: null } },
14
context: { computeHierarchyPaths: true },
15
})

Method 2: Query Parameter

For REST API requests, add the computeHierarchyPaths query parameter:

1
# Get single document with paths
2
GET /api/pages/abc123?computeHierarchyPaths=true
3
4
# Query collection with paths
5
GET /api/pages?computeHierarchyPaths=true&where[parent][equals]=null

Method 3: Field Selection

Paths are automatically computed when you explicitly select them:

1
const page = await payload.findByID({
2
collection: 'pages',
3
overrideAccess: true,
4
id: 'page-id',
5
select: {
6
title: true,
7
_h_slugPath: true,
8
_h_titlePath: true,
9
},
10
})
11
12
// REST API
13
// 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:

1
// Load 50 folders with same parent
2
const folders = await payload.find({
3
collection: 'folders',
4
overrideAccess: true,
5
where: { parent: { equals: 'parent-id' } },
6
context: { computeHierarchyPaths: true },
7
})
8
// 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):

1
{
2
slug: 'pages',
3
fields: [
4
{
5
name: 'title',
6
type: 'text',
7
required: true,
8
}
9
],
10
hierarchy: {
11
parentFieldName: 'parent', // Field will be auto-created
12
}
13
}

With Custom Parent Field

Define the parent field yourself for custom validation or UI:

1
{
2
slug: 'pages',
3
fields: [
4
{
5
name: 'parentPage',
6
type: 'relationship',
7
relationTo: 'pages', // Must be self-referential
8
hasMany: false, // Must be single relationship
9
admin: {
10
description: 'Select a parent page',
11
position: 'sidebar',
12
},
13
validate: (value, { id }) => {
14
if (value === id) {
15
return 'Document cannot be its own parent'
16
}
17
return true
18
},
19
},
20
{
21
name: 'title',
22
type: 'text',
23
}
24
],
25
hierarchy: {
26
parentFieldName: 'parentPage', // References your custom field
27
}
28
}

With Custom Options

1
{
2
slug: 'pages',
3
fields: [
4
{
5
name: 'title',
6
type: 'text',
7
}
8
],
9
hierarchy: {
10
parentFieldName: 'parent',
11
12
// Optional: Custom slugify function
13
slugify: (text) => {
14
return text.toLowerCase()
15
.replace(/[^\w\s-]/g, '')
16
.replace(/\s+/g, '-')
17
},
18
19
// Optional: Custom field names
20
slugPathFieldName: '_breadcrumbPath',
21
titlePathFieldName: '_breadcrumbTitle',
22
}
23
}

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

Basic Example

1
import type { CollectionConfig } from 'payload'
2
3
// Pages collection - self-referential hierarchy
4
export const Pages: CollectionConfig = {
5
slug: 'pages',
6
admin: {
7
useAsTitle: 'title',
8
},
9
fields: [
10
{
11
name: 'title',
12
type: 'text',
13
required: true,
14
},
15
],
16
hierarchy: {
17
parentFieldName: 'parent',
18
// No relationTo specified = self-referential (pages under pages only)
19
},
20
}
21
22
// Posts collection - polymorphic hierarchy
23
export const Posts: CollectionConfig = {
24
slug: 'posts',
25
admin: {
26
useAsTitle: 'title',
27
},
28
fields: [
29
{
30
name: 'title',
31
type: 'text',
32
required: true,
33
},
34
],
35
hierarchy: {
36
parentFieldName: 'parent',
37
relationTo: ['pages', 'posts'], // Posts can nest under pages OR other posts
38
},
39
}

This enables organizing content like:

1
Pages Hierarchy:
2
- Home (page)
3
└─ About (page)
4
└─ Blog (page)
5
├─ First Post (post under page)
6
│ └─ Reply (post under post)
7
└─ Second Post (post under page)

Creating Documents with Polymorphic Parents

When creating a document with a polymorphic parent, specify both the collection and ID:

1
// Create a page
2
const blogPage = await payload.create({
3
collection: 'pages',
4
overrideAccess: true,
5
data: {
6
title: 'Blog',
7
parent: null,
8
},
9
})
10
11
// Create a post under the page (cross-collection parent)
12
const post = await payload.create({
13
collection: 'posts',
14
overrideAccess: true,
15
data: {
16
title: 'First Post',
17
parent: {
18
relationTo: 'pages', // Parent is a page
19
value: blogPage.id,
20
},
21
},
22
})
23
24
// Create a reply post under the post (same-collection parent)
25
const reply = await payload.create({
26
collection: 'posts',
27
overrideAccess: true,
28
data: {
29
title: 'Reply',
30
parent: {
31
relationTo: 'posts', // Parent is another post
32
value: post.id,
33
},
34
},
35
})

Path Computation

Paths are computed across collections automatically:

1
// Get post with computed paths
2
const post = await payload.findByID({
3
collection: 'posts',
4
overrideAccess: true,
5
id: post.id,
6
context: { computeHierarchyPaths: true },
7
})
8
9
// post._h_slugPath: 'blog/first-post'
10
// post._h_titlePath: 'Blog/First Post'
11
12
// Get reply with nested path
13
const reply = await payload.findByID({
14
collection: 'posts',
15
overrideAccess: true,
16
id: reply.id,
17
context: { computeHierarchyPaths: true },
18
})
19
20
// reply._h_slugPath: 'blog/first-post/reply'
21
// reply._h_titlePath: 'Blog/First Post/Reply'

Use Cases

Blog Posts Under Pages

1
export const Pages: CollectionConfig = {
2
slug: 'pages',
3
hierarchy: {
4
parentFieldName: 'parent',
5
// Self-referential only
6
},
7
}
8
9
export const Posts: CollectionConfig = {
10
slug: 'posts',
11
hierarchy: {
12
parentFieldName: 'parent',
13
relationTo: ['pages', 'posts'], // Posts can go under pages or nest under other posts
14
},
15
}

Result: Blog section can be a page with posts nested underneath, posts can have threaded replies.

Products Under Categories

1
export const Categories: CollectionConfig = {
2
slug: 'categories',
3
hierarchy: {
4
parentFieldName: 'parent',
5
// Self-referential only
6
},
7
}
8
9
export const Products: CollectionConfig = {
10
slug: 'products',
11
hierarchy: {
12
parentFieldName: 'parent',
13
relationTo: ['categories', 'products'], // Products under categories, or product variants under products
14
},
15
}

Result: Categories form the main tree, products nest within categories, product variants can nest under products.

Mixed Content Tree

1
export const Pages: CollectionConfig = {
2
slug: 'pages',
3
hierarchy: {
4
parentFieldName: 'parent',
5
},
6
}
7
8
export const Folders: CollectionConfig = {
9
slug: 'folders',
10
hierarchy: {
11
parentFieldName: 'parent',
12
relationTo: ['pages', 'folders'], // Folders under pages or folders
13
},
14
}
15
16
export const Documents: CollectionConfig = {
17
slug: 'documents',
18
hierarchy: {
19
parentFieldName: 'parent',
20
relationTo: ['pages', 'folders'], // Documents under pages or folders
21
},
22
}

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:

1
// This will fail:
2
// Page A -> Post B -> Page A (circular)
3
4
const pageA = await payload.create({
5
collection: 'pages',
6
overrideAccess: true,
7
data: { title: 'Page A', parent: null },
8
})
9
10
const postB = await payload.create({
11
collection: 'posts',
12
overrideAccess: true,
13
data: {
14
title: 'Post B',
15
parent: { relationTo: 'pages', value: pageA.id },
16
},
17
})
18
19
// ❌ This throws an error
20
await payload.update({
21
collection: 'pages',
22
overrideAccess: true,
23
id: pageA.id,
24
data: { parent: { relationTo: 'posts', value: postB.id } },
25
})
26
// 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:

1
export const Posts: CollectionConfig = {
2
slug: 'posts',
3
fields: [
4
{
5
name: 'parent',
6
type: 'relationship',
7
relationTo: ['pages', 'posts'], // Must match hierarchy config
8
hasMany: false,
9
admin: {
10
position: 'sidebar',
11
},
12
},
13
{
14
name: 'title',
15
type: 'text',
16
},
17
],
18
hierarchy: {
19
parentFieldName: 'parent',
20
relationTo: ['pages', 'posts'], // Must match field config
21
},
22
}

Use Cases

Nested Pages

1
{
2
slug: 'pages',
3
admin: {
4
useAsTitle: 'title',
5
},
6
fields: [
7
{
8
name: 'title',
9
type: 'text',
10
required: true,
11
},
12
{
13
name: 'content',
14
type: 'richText',
15
},
16
],
17
hierarchy: {
18
parentFieldName: 'parent',
19
}
20
}

Nested Categories

1
{
2
slug: 'categories',
3
admin: {
4
useAsTitle: 'name',
5
},
6
fields: [
7
{
8
name: 'name',
9
type: 'text',
10
required: true,
11
},
12
],
13
hierarchy: {
14
parentFieldName: 'parentCategory',
15
}
16
}

Organizational Structure

1
{
2
slug: 'departments',
3
admin: {
4
useAsTitle: 'deptName',
5
},
6
fields: [
7
{
8
name: 'deptName',
9
type: 'text',
10
required: true,
11
},
12
],
13
hierarchy: {
14
parentFieldName: 'parentDept',
15
slugPathFieldName: '_orgPath',
16
titlePathFieldName: '_orgBreadcrumb',
17
}
18
}

Localization Support

If the title field (from admin.useAsTitle) is localized, path fields are automatically localized:

1
{
2
slug: 'pages',
3
admin: {
4
useAsTitle: 'title',
5
},
6
fields: [
7
{
8
name: 'title',
9
type: 'text',
10
required: true,
11
localized: true, // Enables localized paths
12
},
13
],
14
hierarchy: {
15
parentFieldName: 'parent',
16
}
17
}

Result:

1
{
2
title: {
3
en: 'Products',
4
fr: 'Produits',
5
},
6
_h_slugPath: {
7
en: 'store/products',
8
fr: 'magasin/produits',
9
},
10
_h_titlePath: {
11
en: 'Store/Products',
12
fr: 'Magasin/Produits',
13
}
14
}

How It Works

Parent Relationship Management

Hierarchy maintains parent-child relationships through a simple parent field:

  1. Validates there are no circular references when parent changes
  2. Cleans up orphaned children when a parent is deleted (sets their parent to null)
  3. No cascade updates needed - paths are always computed fresh

Example:

1
// Move "Page C" from under "Page A" to under "Page B"
2
await payload.update({
3
collection: 'pages',
4
overrideAccess: true,
5
id: 'page-c',
6
data: { parent: 'page-b' }, // Changed from page-a
7
})
8
9
// Only updates:
10
// - page-c's parent field: 'page-a' → 'page-b'
11
// - No descendant updates needed
12
// - Paths computed fresh on next read

Path Computation

Path fields are computed on-demand when requested:

  1. Walk Parent Chain: Recursively follows parent relationships to root
  2. Build Paths: Concatenates titles/slugs from ancestors in correct order
  3. Cache Results: Ancestors cached in req.context for the request duration
  4. 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:

1
// Change a parent's title from "Products" to "Items"
2
await payload.update({
3
collection: 'pages',
4
overrideAccess: true,
5
id: 'parent-id',
6
data: { title: 'Items' },
7
})
8
9
// Next time a child is read with path computation:
10
const child = await payload.findByID({
11
collection: 'pages',
12
overrideAccess: true,
13
id: 'child-id',
14
context: { computeHierarchyPaths: true },
15
})
16
17
// child._h_titlePath reflects the new parent title:
18
// Old: "Products/Widget"
19
// 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 parent field 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:

1
// Moving a folder with 50 descendant pages
2
await payload.update({
3
collection: 'pages',
4
overrideAccess: true,
5
id: 'folder-id',
6
data: { parent: 'new-parent' },
7
})
8
// Result:
9
// - Folder: ✅ parent field updated
10
// - 50 descendants: No updates (still point to folder-id)
11
// - 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:

1
// Reading published version
2
const published = await payload.findByID({
3
collection: 'pages',
4
overrideAccess: true,
5
id: 'page-id',
6
context: { computeHierarchyPaths: true },
7
// draft: false (default)
8
})
9
// published._h_slugPath: 'products/clothing' (uses published parent title)
10
11
// Reading draft version
12
const draft = await payload.findByID({
13
collection: 'pages',
14
overrideAccess: true,
15
id: 'page-id',
16
draft: true,
17
context: { computeHierarchyPaths: true },
18
})
19
// draft._h_slugPath: 'products/apparel' (uses draft title if changed)

How it works:

  1. When computing paths, hierarchy fetches ancestors using the same draft context
  2. If reading a draft, ancestor titles come from draft versions (if they exist)
  3. If reading published, ancestor titles come from published versions
  4. 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 When Publishing a Single Locale

Use with caution: When publishing a single locale (locale: '<code>' with _status: 'published'), 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 for one locale:

  • 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:

1
// Published document with localized titles
2
{
3
parent: 'parent-1',
4
title: { en: 'Page', fr: 'Page' },
5
_h_slugPath: { en: 'products/page', fr: 'produits/page' }
6
}
7
8
// Draft changes parent
9
await payload.update({
10
collection: 'pages',
11
overrideAccess: true,
12
id: 'doc-id',
13
data: { parent: 'parent-2' },
14
draft: true,
15
})
16
17
// Publish only French
18
await payload.update({
19
collection: 'pages',
20
overrideAccess: true,
21
id: 'doc-id',
22
locale: 'fr',
23
data: { _status: 'published' },
24
draft: false,
25
})
26
// Result: parent changed to 'parent-2' for ALL locales
27
// 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:

1
// ✅ Clear: publishes parent change for all locales
2
await payload.update({
3
collection: 'pages',
4
overrideAccess: true,
5
id: 'doc-id',
6
data: { parent: 'new-parent' },
7
// publishAllLocales is default
8
})

Note: Title changes work as expected when publishing a single locale, since paths are computed per locale.

Best Practices

Use Consistent Title Fields

Ensure your admin.useAsTitle field is stable and always has a value:

1
{
2
admin: {
3
useAsTitle: 'title', // ✅ Clear, user-facing field
4
},
5
fields: [
6
{
7
name: 'title',
8
type: 'text',
9
required: true, // ✅ Always has a value
10
}
11
]
12
}

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: true only 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:

1
// ❌ Unnecessary path computation for a tag relationship
2
const post = await payload.findByID({
3
collection: 'posts',
4
overrideAccess: true,
5
id: 'post-id',
6
depth: 2, // Populates category relationship
7
context: { computeHierarchyPaths: true }, // Computes paths for category too
8
})
9
10
// ✅ Only compute paths when you'll use them
11
const post = await payload.findByID({
12
collection: 'posts',
13
overrideAccess: true,
14
id: 'post-id',
15
depth: 2,
16
// No path computation - category.parent populated but no _h_slugPath
17
})

TypeScript

The hierarchy fields are automatically included in your generated types:

1
import type { Page } from './payload-types'
2
3
// Fetched document type includes all hierarchy fields
4
const page = await payload.findByID({
5
collection: 'pages',
6
overrideAccess: true,
7
id: 'page-id',
8
context: { computeHierarchyPaths: true },
9
})
10
11
// TypeScript knows about these fields:
12
const id: string = page.id
13
const title: string = page.title
14
const parent: string | null = page.parent
15
const slugPath: string = page._h_slugPath // Virtual field, computed when requested
16
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:

1
payload generate:types

Was this page helpful?

Next

Email Functionality