Skip to content

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.jsonadmin: { component: "menu-items" } is what swaps the raw JSON textarea for the tree editor. Marking it translatable gives each locale its own item tree.

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.

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

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.

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.

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": [] }]
}
]

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. posts
category: 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.

---
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);

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.

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