BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Payload Nested Docs Plugin: Multi-Tenant Breadcrumbs Guide

Payload Nested Docs Plugin: Multi-Tenant Breadcrumbs Guide

Step-by-step setup and tenant-scoped fixes for Payload nested docs—localized breadcrumbs, secure parent picker, and…

17th August 2026·Updated on:19th August 2026··
Payload
Payload Nested Docs Plugin: Multi-Tenant Breadcrumbs Guide

Evaluating Payload CMS Implementation Costs?

Scope design, content structure, and migration hours to estimate a realistic production timeline and hosting setup.

Try the Cost EstimatorGet a Second Opinion

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • What the Nested Docs Plugin Actually Adds
  • Installing and Configuring the Plugin
  • Restricting Parent Selection to a Single Tenant
  • Resolving Nested URLs on the Frontend
  • Rendering the Breadcrumb Trail
  • Handling Localized Nested URLs
  • Nested Docs vs Hand-Rolled Recursion vs Flat Slugs
  • FAQ
  • Conclusion
On this page:
  • What the Nested Docs Plugin Actually Adds
  • Installing and Configuring the Plugin
  • Restricting Parent Selection to a Single Tenant
  • Resolving Nested URLs on the Frontend
  • Rendering the Breadcrumb Trail
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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 production-ready multi-tenant setup guide 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

ApproachWhen to useTrade-off
@payloadcms/plugin-nested-docsAny Payload project needing multi-level page hierarchies with breadcrumbsWrite-time cost on deep trees when a parent renames, since every descendant resaves sequentially
Hand-rolled recursive queries at read timeFull custom control over the hierarchy logic is requiredAdds 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 manuallyVery small, rarely-changing page setsProne 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