Menus and Taxonomies
Menus and taxonomies are patterns you add, not built-in collections. You define ordinary collections in your project and point their fields at built-in admin components: menu-items and taxonomy-terms render a drag-and-drop tree editor, and taxonomy-select renders a searchable term picker on any collection. The admin sidebar recognizes collections with the slugs menus and taxonomies and files them under the Library group with dedicated icons.
A menu is a document with a name, a slug, and a JSON tree of items. Add the collection and register it in cms.config.ts.
src/cms/collections/menus.ts:
import { defineCollection, fields } from "@kidecms/core";
// The tree editor caps nesting (root → section → link), but that only guards// the admin UI — this hook is what actually rejects a malformed save,// including writes made directly through the API/MCP tools.const MENU_MAX_DEPTH = 2;
type MenuItem = { id?: unknown; label?: unknown; children?: unknown };
function assertMenuDepth(items: unknown, depth = 0): void { if (!Array.isArray(items)) return; for (const item of items as MenuItem[]) { const children = item.children; if (depth >= MENU_MAX_DEPTH && Array.isArray(children) && children.length > 0) { const name = String(item.label ?? item.id ?? "item"); throw new Error( `Menu items may only nest ${MENU_MAX_DEPTH + 1} levels deep (root → section → link) — "${name}" has a child that would never render.`, ); } assertMenuDepth(children, depth + 1); }}
export default defineCollection({ slug: "menus", labels: { singular: "Menu", plural: "Menus" }, timestamps: true, views: { list: { columns: ["name", "slug", "_updatedAt"] }, }, fields: { name: fields.text({ required: true }), slug: fields.slug({ from: "name", admin: { position: "sidebar" } }), items: fields.json({ admin: { component: "menu-items" }, }), }, hooks: { beforeCreate(data) { if (data.items !== undefined) assertMenuDepth(data.items); return data; }, beforeUpdate(data) { if (data.items !== undefined) assertMenuDepth(data.items); return data; }, afterCreate(_doc, context) { context.cache?.invalidate({ tags: ["menus"] }); }, afterUpdate(_doc, context) { context.cache?.invalidate({ tags: ["menus"] }); }, afterDelete(_doc, context) { context.cache?.invalidate({ tags: ["menus"] }); }, },});The items field is a plain fields.json — admin: { component: "menu-items" } is what swaps the raw JSON textarea for the tree editor. Marking it translatable gives each locale its own item tree.
The menu editor
Section titled “The menu editor”Each menu item has a label and a link. The link is either an external URL typed by hand or an internal link chosen from a picker that lists published documents from your content collections (non-singleton collections that have a slug field; users, menus, taxonomies, and authors are excluded, up to 200 documents per collection). Picking an internal document stores its resolved route as a plain href string — a snapshot, so menu links do not follow later slug changes.
Editor operations:
- Drag the grip handle to reorder items within the same level.
- Indent / outdent buttons move an item under its previous sibling or back up a level.
- Add child adds a nested item under any item.
- Nesting is capped at 3 levels (root → section → link). The cap only guards the editor UI, which is why the collection above adds the depth hook for API writes.
Stored item shape
Section titled “Stored item shape”items is an array of nested objects:
[ { "id": "ti_x1y2z3a", "label": "Products", "href": "/products", "children": [ { "id": "ti_b4c5d6e", "label": "Pricing", "href": "/pricing", "target": "_blank", "children": [] } ] }]| Key | Type | Notes |
|---|---|---|
id |
string | Generated by the editor, stable across edits |
label |
string | Link text |
href |
string | External URL or internal route |
target |
string | "_blank" for new tab; absent otherwise |
children |
array | Nested items, same shape |
Rendering a menu
Section titled “Rendering a menu”Query the menu by slug and walk the tree — there is no special helper, items comes back as a parsed array:
---import { cms } from "@/cms/.generated/api";import { safeUrl } from "@kidecms/core";
type MenuItem = { id: string; label?: string; href?: string; target?: string; children: MenuItem[] };
const menu = await cms.menus.findOne({ slug: "main" });const rawItems: MenuItem[] = Array.isArray(menu?.items) ? menu.items : [];// Menu hrefs are CMS-authored content — sanitize and drop anything unsafe.const sanitize = (items: MenuItem[]): MenuItem[] => items .map((item) => ({ ...item, href: safeUrl(String(item.href ?? "")) ?? undefined, children: sanitize(item.children) })) .filter((item) => item.href !== undefined);const items = sanitize(rawItems);---
<nav> <ul> { items.map((item) => ( <li> <a href={item.href} target={item.target || undefined} rel={item.target === "_blank" ? "noopener noreferrer" : undefined} > {item.label} </a> {item.children.length > 0 && ( <ul> {item.children.map((child) => ( <li> <a href={child.href} target={child.target || undefined} rel={child.target === "_blank" ? "noopener noreferrer" : undefined} > {child.label} </a> </li> ))} </ul> )} </li> )) } </ul></nav>For a translated menu, pass locale to findOne.
Taxonomies
Section titled “Taxonomies”A taxonomy is a document holding a named tree of terms (e.g. a “Categories” taxonomy with nested category terms). Other collections reference a term by its slug through the taxonomy-select component.
src/cms/collections/taxonomies.ts:
import { defineCollection, fields } from "@kidecms/core";
export default defineCollection({ slug: "taxonomies", labels: { singular: "Taxonomy", plural: "Taxonomies" }, timestamps: true, views: { list: { columns: ["name", "slug", "_updatedAt"] }, }, fields: { name: fields.text({ required: true }), slug: fields.slug({ from: "name", admin: { position: "sidebar" } }), terms: fields.json({ admin: { component: "taxonomy-terms" }, }), },});The collection slug must be exactly taxonomies — the taxonomy-select picker fetches its terms from /api/cms/taxonomies.
The terms editor
Section titled “The terms editor”admin: { component: "taxonomy-terms" } renders the same tree editor as menus in taxonomy mode:
- Each term has a name and a slug; the slug auto-generates from the name until you edit it manually.
- Bulk add: type comma-separated names (
Electronics, Clothing, Books), optionally pick a parent term, and add them all at once. - Drag to reorder, indent/outdent, add children — nesting depth is unlimited for taxonomies.
Stored shape, an array of nested objects:
[ { "id": "ti_f7g8h9i", "name": "Electronics", "slug": "electronics", "children": [{ "id": "ti_j1k2l3m", "name": "Phones", "slug": "phones", "children": [] }] }]Assigning terms to content
Section titled “Assigning terms to content”Put a text field with admin.component: "taxonomy-select" on any collection. The admin.placeholder value names the taxonomy document to read terms from (matched against its slug); it also doubles as the picker’s placeholder text.
// in another collection, e.g. postscategory: fields.text({ admin: { component: "taxonomy-select", placeholder: "categories", position: "sidebar" },}),This renders a searchable dropdown of the taxonomy’s terms, indented to show hierarchy. The selected term’s slug is stored as the field’s plain text value — so the field filters and queries like any text field.
Querying by term
Section titled “Querying by term”---import { cms } from "@/cms/.generated/api";
const posts = await cms.posts.find({ where: { category: "electronics" }, sort: { field: "_updatedAt", direction: "desc" },});---To display a term’s name instead of its slug, load the taxonomy document and look the slug up in its terms tree:
type Term = { name: string; slug: string; children?: Term[] };
function findTerm(terms: Term[], slug: string): Term | undefined { for (const term of terms) { if (term.slug === slug) return term; const found = findTerm(term.children ?? [], slug); if (found) return found; }}
const taxonomy = await cms.taxonomies.findOne({ slug: "categories" });const term = findTerm(Array.isArray(taxonomy?.terms) ? taxonomy.terms : [], post.category);Admin sidebar placement
Section titled “Admin sidebar placement”Collections with the slugs menus and taxonomies (along with shared-sections, forms, and authors) are automatically grouped under Library in the admin sidebar, with dedicated icons (a menu icon and a folder tree) and a fixed sort order. Override any of this per collection with admin: { group, icon, weight, sidebar } — see Admin UI.
Components at a glance
Section titled “Components at a glance”| Component | Field type | Config | Stores |
|---|---|---|---|
menu-items |
fields.json |
— | Tree of { id, label, href, target?, children } |
taxonomy-terms |
fields.json |
— | Tree of { id, name, slug, children } |
taxonomy-select |
fields.text |
admin.placeholder = slug of the taxonomy document to pick from |
Selected term’s slug as plain text |