Byline CMS
  • Home
  • Docs
  • About
Byline CMS
View on GitHub
  • Getting Started
    • Experimental CLI
    • Development environment and example application
  • Why Byline
    • Mission & Vision
    • Content Management in the Time of AI
  • Key Architectural Decisions
    • Core Document Storage
    • Core Composition
    • Transactions
  • Collections
    • Fields API
    • Relationships
    • Document Trees
    • Document Paths
    • File / Media Uploads
    • Rich Text Editor
    • Collection Versioning
  • Reading & Delivery
    • Client SDK (@byline/client)
    • Routing & API
    • Transports
    • Markdown Export
    • MCP Server
    • Caching
  • Auth & Security
    • Authentication & Authorization
    • Auditability
  • Internationalization (i18n)
    • The host i18n system
    • Admin interface translations
    • Content locales
    • Administering content locales
  • Admin UI
    • UI Kit (@byline/ui)
    • Client-config registration
  • Testing
  • Home

Document Trees

A document tree turns a collection into a navigable hierarchy: an ordered, single-parent table of contents where every document knows its place. It is the structural backbone for documentation sites, handbooks, and books — anywhere a set of documents needs a spine, breadcrumbs, and previous/next navigation. This very documentation site is a document tree.

The tree is metadata about position, not content. Where a document sits in the table of contents says nothing about what the document is. So reordering, nesting, and re-parenting touch no content fields and mint no new versions — they behave exactly like editing a document's path. The structure lives in its own table, at document level, outside the version stream. The tree edge is one of the three document-level system attributes (alongside path and availableLocales); see Architecture → Document level vs version level for why that distinction matters and how it relates to versioning and audit.

Enabling a tree

Opt in at the collection definition (schema) level, not in defineAdmin — the flag changes how the collection is stored and read, not merely how it renders:

export const Docs = defineCollection({
path: 'docs',
useAsTitle: 'title',
useAsPath: 'title',
tree: true, // ← turns on the document-tree primitive
fields: [ /* … no `parent` field; the tree owns structure … */ ],
})

Turning on tree: true enables four things: the tree storage and its commands, the authoring widget and tree list view in the admin, the hierarchical read path, and the afterTreeChange invalidation event. A tree collection owns its own ordering, so it cannot also be orderable: true — that combination is rejected at config validation. You do not add a parent relation field; the tree owns the parent edge.

The three states of a node

Every document in a tree collection is in exactly one of three states:

State

Meaning

Unplaced

The document exists but has no position in the table of contents.

Root

A top-level node, ordered among the other roots.

Child

A nested node, ordered among its siblings under one parent.

In normal use you rarely see unplaced: every new document in a tree collection is automatically placed as a root when it is created, so the tree is never half-built. Authors then drag nodes into position or set a parent from the editor. Should a document ever end up unplaced (for example, created through a path that skipped auto-placement), saving it again re-roots it, and the editor widget offers an explicit Add to tree action.

The rules that hold

  • Single collection. A tree relates documents within one collection (docs → docs). There are no cross-collection trees.
  • Single parent. Each document has at most one parent. A topic that genuinely belongs in two places is expressed with an ordinary cross-link relation field ("See also"), never a second tree edge.
  • Deleting a node promotes its children. Children are never cascade-deleted; when their parent is removed they are promoted to root and survive.
  • Per-parent ordering. Sibling order is scoped to the parent — each parent is its own ordering keyspace, so reordering one branch never disturbs another.
  • Locale-agnostic structure. The tree references the logical document, so all locale variants of a document share one position. A localized documentation site has one structure with localized content hanging off each node.

Authoring

A tree collection swaps the default list view for a tree list: ordered rows with depth-indented children and an Unplaced group for any stragglers. The placed tree is drag-enabled — a grip handle drags a node together with its whole subtree, and the drag's horizontal offset projects the target depth and parent (the Notion-style indent gesture), clamped to what the neighbouring rows allow. Drops persist immediately and optimistically, reverting with a toast on failure.

The document editor also carries a tree-placement widget in its sidebar (directly alongside the path widget). It uses the collection's own relation picker to choose a parent, with a "Move to top level" action and, for an unplaced document, "Add to tree". Editing structure here is immediate and unversioned, just like editing the path.

Reading a tree

The tree is not stored in the relation store, so it has its own read path rather than flowing through relationship population. The primitives are exposed on the @byline/client collection handle and mirror the storage commands:

Method

Purpose

getSubtree({ rootDocumentId | null, depth, status })

Read a nested subtree (node + ordered children). A null root reads the whole tree from its roots.

getAncestors({ documentId })

Walk upward for breadcrumbs — an indexed single-collection lookup.

getTreeParent({ documentId })

Distinguish unplaced (no edge) from root (edge, null parent) from child (edge, parent set).

placeTreeNode({ documentId, parentDocumentId | null, before | after })

Place, reorder, or re-parent a node — they differ only in whether the parent changes.

removeFromTree({ documentId })

Remove a node's edge so it becomes unplaced (distinct from deleting the document).

Subtree reads run a depth-bounded recursive query and return the nodes in table-of-contents (pre-order) order. The prev/next spine is a depth-first flatten of that ordered tree: a document's neighbours are simply the entries adjacent to it in the flattened list. Breadcrumbs come from getAncestors.

For example, building a navigation tree and a document's breadcrumb + prev/next neighbours from a frontend loader:

// The whole published docs tree, in table-of-contents order.
const nav = await client.collection('docs').getSubtree({
rootDocumentId: null,
status: 'published',
})
// Breadcrumbs for the current document (root → parent, already ordered).
const ancestors = await client.collection('docs').getAncestors({
documentId: doc.id,
})
// Prev/next: flatten the ordered tree to a linear spine, find the neighbours.
const spine = []
const flatten = (nodes) => {
for (const n of nodes) {
spine.push(n)
if (n.children?.length) flatten(n.children)
}
}
flatten(nav)
const i = spine.findIndex((n) => n.id === doc.id)
const prev = spine[i - 1] ?? null
const next = spine[i + 1] ?? null

Each node carries its path and the full ancestor chain, so a link target is the joined chain (/docs/getting-started/cli) rather than the bare slug — the public URL resolution section below covers how those URLs resolve and self-heal.

Status applies at each edge

A published parent can own a draft-only child. Status is therefore evaluated per edge, not just per document:

  • Public reads (status: 'published') drop any edge whose child has no published version. An unpublished node thus hides its entire subtree from public navigation — even where individual descendants are published. This is the intended semantic: you have not published the chapter, so its topics are not yet navigable. Descendants are not re-promoted to fill the gap.
  • Admin/preview reads (status: 'any') see the full tree.

Because the tree references the logical document, status-at-edge is evaluated against the locale being read, so a localized site narrows correctly per language.

Path and URL are two independent axes

Document trees deliberately separate where a document is stored from how it is reached.

The stored path stays flat. byline_document_paths holds a flat slug (cli), globally unique per (collection, locale, path). Re-parenting, reordering, and nesting write only the tree table and never touch the path row. This is what keeps a document untouched by its position: moving a subtree never rewrites a single stored path. Storing tree-derived paths (getting-started/cli) is intentionally avoided — it would force a re-parent to rewrite the stored path of an entire subtree, reintroducing exactly the coupling the tree exists to remove.

The public URL is composed at read time. A host app serves a tree collection through a splat route that captures the full path after the base (/docs/getting-started/cli). The loader resolves the leaf slug with the ordinary findByPath, derives the ancestor chain with getAncestors, and redirects any non-canonical-but-reachable form to the canonical URL computed from the live tree:

const segments = _splat.split('/') // ['getting-started', 'cli']
const leaf = segments.at(-1)! // 'cli'
const doc = await client.collection('docs')
.findByPath(leaf, { status: 'published', locale }) // unique per collection + locale
if (!doc) throw notFound()
const ancestors = await client.collection('docs').getAncestors(doc.id)
const canonical = [...ancestors.map((a) => a.path), doc.path].join('/')
if (canonical !== _splat) {
throw redirect({ to: `/${lng}/docs/${canonical}`, statusCode: 301 })
}
return { doc, ancestors } // ancestors → breadcrumbs, already ordered

This shape has three useful properties:

  • Cheap resolution — one indexed findByPath plus one bounded ancestor walk (O(1) + O(depth)). The intermediate segments are used only to validate and canonicalize, never to locate the document.
  • Self-healing URLs — every reachable-but-non-canonical form (wrong ancestors, the bare /docs/cli, or a stale URL after a re-parent) 301s to the one true URL derived from the live tree. There is no stored redirect table; the tree is the source of truth.
  • Status-at-edge for free — running getAncestors under status: 'published' drops at the first unpublished ancestor, so an unreachable chain 404s, enforcing "an unpublished node hides its subtree" at the URL layer.

The markdown / agent surface (the .md channel and llms.txt) inherits the same splat treatment, so hierarchical URLs work for Accept: text/markdown consumers too.

Because the leaf slug stays globally unique per collection, the ancestor segments are structural context rather than part of resolution — you cannot yet have getting-started/install and advanced/install coexist as distinct documents. A non-unique-leaf hierarchy (where the same slug lives under different parents) would require a composite-key resolver and is a separate concern, not part of the tree primitive.

Invalidation

Because tree mutations mint no version, the usual version-write invalidation does not fire. Each structural write instead emits the afterTreeChange collection hook, whose payload is the affected set rather than a single node — one structural change ripples outward to:

  • the moved node and every descendant (their breadcrumb trails changed),
  • the old parent's child list and the new parent's child list,
  • the prev/next neighbours on both sides of the flatten (old and new positions).

A single drag that relocates a subtree is therefore one logical event over many edges, batched — not one event per edge. Consumers (cache/ISR invalidation, markdown-export refresh, search reindexing) subscribe to this single event.

Choosing a hierarchy model

The document tree is the right choice for an arbitrary-depth spine with a single canonical ordering — documentation, handbooks, books. Two adjacent shapes call for different tools:

  • Multi-home topics — a topic that belongs under several parents is a cross-link, expressed with an ordinary "See also" relation field, not a second tree edge. The tree keeps one canonical spine; cross-links add the lateral connections.
  • Firmly two-level, parent-owns-an-ordered-list structures — a parent that owns an ordered array of children (with order as the array index) is better modelled with a hasMany relation. The tree and that model address different shapes (arbitrary depth versus a fixed two levels) and do not replace one another.
docsPreviousRelationships
docsNextDocument Paths
Byline CMS

Building the future of content management, one commit at a time.

Project

  • Documentation
  • Roadmap
  • Contributing
  • Releases

Community

  • GitHub Discussions
  • Blog
  • Newsletter

Legal

  • Privacy Policy
  • Terms of Use
  • Cookies

© 2026 Infonomic Company Limited and contributors. Open source and built with ❤️ by the community.