---
title: "Payload Nested Docs Plugin: Multi-Tenant Breadcrumbs Guide"
slug: "payload-nested-docs-multi-tenant-breadcrumbs"
published: "2026-08-17"
updated: "2026-08-19"
validated: "2026-08-19"
categories:
  - "Payload"
tags:
  - "Payload nested docs plugin"
  - "nested docs"
  - "Payload CMS"
  - "multi-tenant Payload"
  - "breadcrumbs"
  - "tenant isolation"
  - "generateURL"
  - "filterOptions tenant"
  - "localized breadcrumbs"
  - "Next.js catch-all routing"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "@payloadcms/plugin-nested-docs"
  - "next.js"
  - "typescript"
  - "pnpm"
status: "stable"
llm-purpose: "Payload nested docs plugin: add parent-child pages and localized, localized breadcrumbs for multi-tenant Payload CMS. Secure parent selection and resolve…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to @payloadcms/plugin-nested-docs"
  - "Access to Next.js"
  - "Access to TypeScript"
  - "Access to pnpm"
llm-outputs:
  - "Completed outcome: Payload nested docs plugin: add parent-child pages and localized, localized breadcrumbs for multi-tenant Payload CMS. Secure parent selection and resolve…"
---

**Summary Triples**
- (@payloadcms/plugin-nested-docs, adds, self-referential parent field + precomputed breadcrumbs array to a Payload collection)
- (plugin default parent picker, allows, selecting parents across tenants (unsafe in multi-tenant setups))
- (fix, scope, parent picker using filterOptions (or admin hooks) to restrict choices to the current tenant)
- (frontend, should use, precomputed breadcrumbs to build nested, localized URLs instead of recursive DB queries)
- (plugin, handles, cascading updates when parent slugs/names change (keeps child breadcrumbs in sync))
- (testedWith, Payload and plugin versions, Payload 3.88.0 and @payloadcms/plugin-nested-docs 3.88.0)
- (URL strategy, works with, Next.js catch-all routing and localized locales (example: en and fr-CA))
- (migration, may require, backfilling breadcrumbs for existing pages and re-scoping parent references per tenant)

### {GOAL}
Payload nested docs plugin: add parent-child pages and localized, localized breadcrumbs for multi-tenant Payload CMS. Secure parent selection and resolve…

### {PREREQS}
- Access to Payload CMS
- Access to @payloadcms/plugin-nested-docs
- Access to Next.js
- Access to TypeScript
- Access to pnpm

### {STEPS}
1. Install the plugin dependency
2. Configure generateLabel and generateURL
3. Register plugin before multi-tenant
4. Restrict parent picker to tenant
5. Handle TypeScript Where typing
6. Resolve pages by breadcrumbs on frontend
7. Render localized breadcrumb trail

<!-- llm:goal="Payload nested docs plugin: add parent-child pages and localized, localized breadcrumbs for multi-tenant Payload CMS. Secure parent selection and resolve…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to @payloadcms/plugin-nested-docs" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to pnpm" -->
<!-- llm:output="Completed outcome: Payload nested docs plugin: add parent-child pages and localized, localized breadcrumbs for multi-tenant Payload CMS. Secure parent selection and resolve…" -->

# Payload Nested Docs Plugin: Multi-Tenant Breadcrumbs Guide
> Payload nested docs plugin: add parent-child pages and localized, localized breadcrumbs for multi-tenant Payload CMS. Secure parent selection and resolve…
Matija Žiberna · 2026-08-17

The `@payloadcms/plugin-nested-docs` package adds a self-referential parent field and a precomputed breadcrumbs array to any Payload collection, turning a flat page list into a real content hierarchy without writing a single recursive database query. I added it to a multi-tenant Payload project to support multi-segment URLs like `/company/about/team`, and the plugin handled the parent-child relationship, the breadcrumb trail, and the cascading updates when a parent page moves or gets renamed. Tested with Payload 3.88.0 and the plugin at the matching version, in a project running Next.js catch-all routing and localized content across English and French Canadian locales.

I hit this while building an informational site inside a multi-tenant Payload project. Pages had grown from a handful of top-level entries into nested sections with sub-pages several levels deep, and the flat `pages` collection had no way to represent that structure. Editors would have had to type full composite paths directly into a single slug field, which invites typos and breaks every child link the moment a parent page gets renamed. The official nested docs plugin solves the relationship and the breadcrumb trail out of the box. The one gap it leaves open in a multi-tenant setup is tenant isolation: its default parent-selection filter lets an editor pick a parent page from any tenant's site, which needs a custom fix before it's safe to ship. This guide walks through the plugin's mechanics, the exact configuration used in production, that tenant-scoped fix, and how the frontend resolves nested URLs from the precomputed breadcrumb data.

If you're setting up multi-tenancy from scratch first, my <a href="https://www.buildwithmatija.com/blog/production-ready-multi-tenant-nextjs-payload">production-ready multi-tenant setup guide</a> covers the routing and isolation groundwork this plugin builds on top of.

## What the Nested Docs Plugin Actually Adds

The plugin is a first-party Payload package, maintained inside the official `payloadcms/payload` monorepo and published in lockstep with Payload core. Installing version `3.88.0` alongside Payload `3.88.0` keeps you on the same compatibility line the plugin was built and tested against.

Registering it on a collection adds two fields and two hooks:

- A `parent` relationship field, capped at one level deep, positioned in the sidebar by default. Editors pick a single parent document from the same collection.
- A `breadcrumbs` array field, marked read-only and localized, storing `{ doc, url, label }` entries for every ancestor in the chain.
- A `beforeChange` hook that walks up the parent chain and rebuilds the breadcrumb array whenever a document saves.
- An `afterChange` hook that resaves every descendant when a parent's label or URL changes, so a rename propagates down the entire subtree automatically.

Two exported helpers do the field creation: `createParentField(relationTo, overrides?)` and `createBreadcrumbsField(relationTo, overrides?)`. Both accept a Payload field config as an override, which is how the tenant-scoped filter later in this guide gets applied without forking the plugin.

One default worth knowing before you configure anything: the plugin does not generate URLs on its own. Without a `generateURL` callback, every breadcrumb entry saves with `url: undefined`. The label falls back to `collection.admin.useAsTitle` or the document ID, but the URL needs an explicit function.

## Installing and Configuring the Plugin

```bash
pnpm add @payloadcms/plugin-nested-docs@3.88.0
```

```ts
// File: src/payload/config/plugins/nested-docs.ts
import { nestedDocsPlugin } from "@payloadcms/plugin-nested-docs";

export const nestedDocs = nestedDocsPlugin({
  collections: ["pages"],
  generateLabel: (_, doc) => String(doc.title ?? ""),
  generateURL: (docs) =>
    docs.reduce((url, doc) => `${url}/${String(doc.slug ?? "")}`, ""),
});
```

`collections` scopes the plugin to `pages` only, so other collections in the project stay untouched. `generateLabel` pulls the document title for breadcrumb display text. `generateURL` reduces the full ancestor chain into a single path by concatenating each ancestor's `slug`, which is what produces `/company/about/team` from three separate documents.

Register the plugin in the project's plugin array before the multi-tenant plugin:

```ts
// File: src/payload/config/plugins/index.ts (line 41)
import { nestedDocs } from "./nested-docs";

export const plugins = [
  nestedDocs,
  multiTenant,
  // ...other plugins
];
```

Order matters here because the multi-tenant plugin also modifies collection configs, and registering nested docs first keeps the field additions predictable when both plugins touch the same collection.

## Restricting Parent Selection to a Single Tenant

The plugin's default parent filter only excludes the current document and prevents circular references. It has no concept of tenants, so without an override, an editor on Site A can select a page from Site B as a parent. That's a real content-isolation bug in any multi-tenant project, not a cosmetic issue: it lets cross-tenant relationships leak into the breadcrumb trail and the generated URL.

The fix overrides `createParentField` directly in the collection's field config:

```ts
// File: src/payload/collections/content/pages/Pages/fields.ts (lines 7-27)
createParentField("pages", {
  admin: {
    position: "sidebar",
  },
  filterOptions: ({ data, id }) => {
    const siteId =
      typeof data?.site === "object" && data?.site !== null
        ? (data.site as { id?: string | number }).id
        : data?.site;

    const andConditions: Where[] = [];
    if (id) {
      andConditions.push({ id: { not_equals: id } });
    }
    if (siteId) {
      andConditions.push({ site: { equals: siteId } });
    }

    return andConditions.length > 0 ? { and: andConditions } : true;
  },
}),
```

`filterOptions` runs on the admin UI's parent picker and constrains the query Payload uses to populate the dropdown. Reading the current document's `site` relationship and adding it as an `equals` condition keeps the picker limited to pages that belong to the same tenant. The `id: { not_equals: id }` condition is preserved from the plugin's own default so a document still can't select itself as its own parent.

One TypeScript detail worth flagging: inline object literals inside `filterOptions` fail validation against Payload's `Where` type. Building the conditions as an explicitly typed `Where[]` array first, then spreading them into `{ and: andConditions }`, is what satisfies the type checker here.

## Resolving Nested URLs on the Frontend

With breadcrumbs precomputed at write time, the frontend query resolves a nested URL by matching against the breadcrumb array instead of running a recursive lookup at request time.

```ts
// File: src/payload/data/content.ts (lines 226-258)
async function queryPageBySlug(input: {
  siteSlug: string;
  locale: SupportedLocale;
  slug: string;
  draft: boolean;
}): Promise<PageView | null> {
  const payload = await getPayload({ config });
  const normalizedUrl = input.slug.startsWith("/")
    ? input.slug
    : `/${input.slug}`;
  const result = await payload.find({
    collection: "pages",
    depth: 0,
    draft: input.draft,
    fallbackLocale: false,
    limit: 1,
    locale: input.locale,
    overrideAccess: true,
    select: {
      breadcrumbs: true,
      id: true,
      layout: true,
      meta: true,
      parent: true,
      slug: true,
      summary: true,
      title: true,
    },
    where: {
      and: [
        { "site.slug": { equals: input.siteSlug } },
        {
          or: [
            { "breadcrumbs.url": { equals: normalizedUrl } },
            { slug: { equals: input.slug } },
          ],
        },
        ...(input.draft ? [] : [{ _status: { equals: "published" as const } }]),
      ],
    },
  });
  return (result.docs[0] as PageView | undefined) ?? null;
}
```

Matching on `slug` alone breaks for nested children, since a child page's own `slug` field only holds its own segment (`team`), not the full path (`/company/about/team`). The `or` clause checks `breadcrumbs.url` first, which holds the full computed path, and falls back to plain `slug` matching for top-level pages that have no breadcrumb trail yet. This is the query that lets a Next.js catch-all route resolve any depth of nesting against one collection without walking the tree at request time.

`depth: 0` keeps the read fast, since the breadcrumb array already contains everything the page needs to render its own trail. There's no need to populate the full ancestor documents for a page view.

## Rendering the Breadcrumb Trail

```tsx
// File: src/components/templates/template-shell.tsx (lines 14-43)
{breadcrumbs && breadcrumbs.length > 1 ? (
  <nav
    aria-label="Breadcrumb"
    className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
  >
    {breadcrumbs.map((crumb, idx) => {
      const isLast = idx === breadcrumbs.length - 1;
      return (
        <span key={idx} className="flex items-center gap-2">
          {idx > 0 ? <span className="opacity-50">/</span> : null}
          {crumb.url && !isLast ? (
            <Link
              href={crumb.url}
              className="transition-colors hover:text-foreground hover:underline"
            >
              {crumb.label}
            </Link>
          ) : (
            <span
              aria-current={isLast ? "page" : undefined}
              className={isLast ? "font-medium text-foreground" : ""}
            >
              {crumb.label}
            </span>
          )}
        </span>
      );
    })}
  </nav>
) : null}
```

The `breadcrumbs.length > 1` guard hides the whole trail on top-level pages, where a single-entry breadcrumb would just repeat the page title. Every non-final crumb renders as a link using the precomputed `url`, and the current page renders as plain text with `aria-current="page"` for screen readers. Nothing here queries the database again; the entire trail comes straight from the `breadcrumbs` field fetched in the page query above.

## Handling Localized Nested URLs

Because the `breadcrumbs` field is localized, each locale carries its own set of translated labels and, more importantly, its own URLs when slugs differ by language. A language switcher that needs to jump to the equivalent page in another locale can pull the leaf breadcrumb entry, strip leading and trailing slashes, and reconstruct the locale-prefixed path from it. That's how a page like `/en-CA/company/about` resolves to `/fr-CA/entreprise/a-propos` without maintaining a separate slug-mapping table.

## Nested Docs vs Hand-Rolled Recursion vs Flat Slugs

| Approach | When to use | Trade-off |
|---|---|---|
| `@payloadcms/plugin-nested-docs` | Any Payload project needing multi-level page hierarchies with breadcrumbs | Write-time cost on deep trees when a parent renames, since every descendant resaves sequentially |
| Hand-rolled recursive queries at read time | Full custom control over the hierarchy logic is required | Adds database latency to every page load and requires building and maintaining the recursion, the cycle guards, and the localization handling yourself |
| Flat composite slugs typed manually | Very small, rarely-changing page sets | Prone to editor typos, no automatic breadcrumb labels, and a parent rename breaks every child link unless each one gets updated by hand |

The plugin's tradeoff is a write-time one. Reads stay at `O(1)` against an indexed field, since the breadcrumb trail is already stored on the document. The cost shows up when a parent with many descendants gets renamed, since the `afterChange` hook triggers a sequential `payload.update` call for every child in the subtree.

## FAQ

**Does the nested docs plugin support localized breadcrumbs?**
Yes. The `breadcrumbs` field ships with `localized: true`, so each locale stores its own translated labels and URLs. Language switchers can read the leaf entry for the active locale to build the equivalent path in another language.

**What happens to child pages when I move or rename a parent?**
The plugin's `afterChange` hook resaves every descendant automatically, recalculating their breadcrumb trails to match the new parent label or URL. On a deep tree, this runs as a sequence of individual update calls, so a rename on a page with dozens of descendants takes noticeably longer than a rename on a leaf page.

**Can I use this plugin in a multi-tenant Payload setup?**
Yes, but not safely with the default configuration. The plugin's built-in `filterOptions` only prevents self-selection and circular references. It has no tenant awareness, so a custom `filterOptions` override that constrains the parent picker to the current tenant is necessary before shipping.

**Does the plugin generate page URLs automatically?**
No. Without a `generateURL` callback in the plugin config, every breadcrumb entry saves with an undefined URL. The label falls back to the document's title or ID, but the path itself needs an explicit function that reduces the ancestor chain into a string.

**How do I match incoming request URLs against nested pages?**
Query against the `breadcrumbs.url` field in addition to the plain `slug` field. A child page's own `slug` only holds its final path segment, so matching on `slug` alone resolves top-level pages correctly but fails on anything nested.

## Conclusion

A flat `pages` collection can't represent a multi-level content structure on its own, and hand-rolling the parent relationship, the breadcrumb computation, and the cascade updates is a meaningful amount of code to build and maintain. The official nested docs plugin covers all three out of the box, tested here against Payload 3.88.0 in a multi-tenant project with localized content. The one piece it doesn't handle for you is tenant isolation on the parent picker, which a short `filterOptions` override closes. Combined with a frontend query that matches against the precomputed `breadcrumbs.url` field, the result is multi-segment URL resolution without a single recursive database call at request time.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Payload nested docs plugin: add parent-child pages and localized, localized breadcrumbs for multi-tenant Payload CMS. Secure parent selection and resolve…",
  "responses": [
    {
      "question": "What does the article \"Payload Nested Docs Plugin: Multi-Tenant Breadcrumbs Guide\" cover?",
      "answer": "Payload nested docs plugin: add parent-child pages and localized, localized breadcrumbs for multi-tenant Payload CMS. Secure parent selection and resolve…"
    }
  ]
}
```