nav.json reference
src/content/nav.json holds the site’s authored navigation tree: the order pages appear in the sidebar, and which of them are collected into collapsible groups.
It is a sidecar — a file of its own, deliberately not a key inside index.json. It is also optional: a site without one gets a flat sidebar in pages[] order, which is exactly how every portal behaved before groups existed.
File shape
Section titled “File shape”{ "entries": [ { "kind": "page", "pageId": "home" }, { "kind": "group", "id": "brand-basics", "label": "Brand Basics", "startCollapsed": false, "pageIds": ["logo", "colors", "typography"] }, { "kind": "page", "pageId": "contact" } ]}entries is the top-level sidebar order. Each entry is one of two kinds.
kind: "page"
Section titled “kind: "page"”| Field | Type | Notes |
|---|---|---|
kind | "page" | Discriminator. |
pageId | string | A page id from index.json’s pages[]. Must be non-empty. |
kind: "group"
Section titled “kind: "group"”| Field | Type | Default | Notes |
|---|---|---|---|
kind | "group" | — | Discriminator. |
id | string | — | Required, non-empty, unique. The group’s stable identity — renaming the group does not change it. |
label | string | "" | Display name. May be blank; the UI then shows “Untitled group”. |
startCollapsed | boolean | false | The viewer’s default state: whether the group renders closed until a reader opens it. Unrelated to anything in the editor. |
pageIds | string[] | [] | Member page ids, in the order they render inside the group. |
Groups do not nest, and a page belongs to at most one group. label, startCollapsed, and pageIds all have defaults so that a hand- or agent-authored group missing a key still parses. Entries are parsed one at a time, so a malformed entry costs you that entry, not the file — the loader keeps every entry that validates, drops the rest, and marks the navigation degraded with a warning naming how many went. id is the one key with no default, so a group without one is the case that gets dropped.
Source of truth: NavFileSchema in packages/primitives/src/schemas/nav.ts.
Reconciliation: the file is a preference, not a constraint
Section titled “Reconciliation: the file is a preference, not a constraint”nav.json is never trusted to match index.json. On every load the authored tree is repaired against the current page set by reconcileNav() (packages/primitives/src/lib/nav-tree.ts), which is total, pure, and idempotent — every possible input maps to a valid nav in which each page appears exactly once:
- An entry naming a page that no longer exists is dropped.
- A page that appears more than once keeps only its first placement.
- A duplicate group
idkeeps only the first group. - A page in
index.jsonthat no nav entry mentions is appended to the end. - A malformed file (or unparseable JSON) degrades to a flat nav, with a warning — never a failed load.
So the worst outcome of a stale or partial nav.json is a page in the wrong position. It cannot brick the viewer or the editor, and there is no state it can reach that needs manual repair.
Read the nav through navEntries(index), which always reconciles. It is the only sanctioned accessor — callers never have to reason about whether index.nav is present, stale, or written by an older package version.
pnpm exec authoring validate reports a parse failure as an error and the repairable cases above as warnings, matching that split: reconciliation has already fixed the warnings by the time the site renders, so they must not fail a build.
Writing it
Section titled “Writing it”Writes are wholesale replacements, and that makes the two directions asymmetric:
- Leave
nav.jsonalone → the existing grouping is preserved. This is the safe default. Editing onlyindex.jsonnever disturbs the nav. - Write
nav.json→ it replaces the previous grouping entirely.
The consequence worth stating outright: {"entries": []} is not a no-op. It is a valid nav that flattens every group on the site. If you are not deliberately changing the grouping, do not write the file.
A nav payload that is not a valid navigation tree is declined, not applied: the rest of the write lands, nav.json is left exactly as it was, and the response carries a warning saying so. So a rejected grouping never corrupts the file — but check the warnings, because the write still reports success.
Through the editor
Section titled “Through the editor”Grouping is managed in the Pages modal — + Add group, then drag pages onto the group header to move them in and out. The editor writes nav.json on save.
Through the MCP connector
Section titled “Through the MCP connector”The nav arrives as a nav key on the index returned by get_site_index and get_site_content, and is written back the same way via save_sections. The same asymmetry applies:
- Omit
navfrom the postedindex→nav.jsonis left untouched. - Include it → the file is replaced. Posting
nav: []deletes every group.
Either post back the nav you read, or leave the key out.
Prefer indexOps over posting a nav at all. Four ops manage grouping by id, so an agent never has to transmit — or reconstruct — the tree:
| op | effect |
|---|---|
addNavGroup | Creates a group (id, optional label / startCollapsed / pageIds). Listing pageIds moves those pages in, in that order. |
removeNavGroup | Removes the grouping only. Member pages survive as top-level entries at the group’s former slot. |
setNavGroupMeta | Edits label and/or startCollapsed. |
movePageInNav | Moves one page to groupId (or null for the top level), at an optional position. |
addPage also takes a navGroupId, so a page can be created directly inside a group.
The safety property differs by op kind, and it is deliberate:
- Section-level ops (
insertAfter,insertAt,move,remove,setMeta,setPageMeta) emit nonavwhatsoever, sonav.jsonis not in the write’s file list and page groups cannot be disturbed. - Page and nav ops do write
nav.json, from a tree run throughreconcileNavagainst the resulting page set — so every surviving page appears exactly once and a removed one cannot linger. This is whyaddPageandremovePagekeep the sidebar correct without the caller managing it.
Note that nav is a key on the index in the wire format only. It is never persisted into index.json — the write path lifts it off before the index is validated, and IndexSchema strips it.
Why a sidecar and not a key in index.json
Section titled “Why a sidecar and not a key in index.json”This is the part not to “simplify” away.
applyContentWrite re-serializes the Zod-parsed index. Zod strips unknown keys, so any writer running a package version that predates a new index key silently deletes that key from disk. Put the nav tree in index.json and the sequence is:
- A current editor saves a grouped site.
index.jsongains anavkey. - A client site (or a connector deploy, or a teammate’s stale checkout) running an older
@drawnagency/*version saves anything at all. - That writer parses the index, does not know about
nav, and writes the file back without it. Every group on the site is gone, with no error and nothing in a log.
A separate file cannot be caught by that: it is simply absent from an old writer’s file list, so it survives untouched. Combined with reconcileNav() tolerating an index.json edited by a writer that has never heard of grouping, the worst mixed-version outcome is a page drifting to the bottom of the sidebar rather than silent, unrecoverable data loss.
That failure mode is the entire reason this file exists. Folding it back into index.json would reintroduce it.
Related
Section titled “Related”- Content model —
index.json, per-section files, and how content is assembled. - Config reference —
portal.config.mjsandsite-config.json.