Concepts
Navigation and mega menus
Model a multi level menu as content, so the people who own the words own the menu, and a renamed page never leaves a dead link behind.
A menu hardcoded in your frontend is a deploy every time marketing renames a thing. A menu built from a table of URLs is worse: it breaks quietly the first time a page moves, and nobody finds out until a customer does.
Modelled as content, a menu is a content type like any other. Your team edits it in the dashboard, your frontend reads it in one request, and a link points at the page itself rather than at a string that used to be its address.
This page builds the three level mega menu below. Everything on it was run against a real project; the JSON is what the API actually returned.
Services ▾
Managed IT Cybersecurity Artificial Intelligence
Keep your systems running Find real exposure Practical AI, tied to a problem
───────────────────────── ───────────────────────── ─────────────────────────
IT Support Penetration Testing AI Readiness
Network Management Incident Response AI Strategy
One type, pointing at itself
The whole menu is a single collection whose items can name a parent.
curl -X PUT https://api.zevcontent.net/v1/schema/content-types/nav-item \
-H "Authorization: Bearer $ZEVCONTENT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Navigation item",
"kind": "collection",
"display_field": "label",
"subtitle_field": "description",
"fields": [
{ "key": "label", "label": "Label", "type": "text", "required": true },
{ "key": "menu", "label": "Menu", "type": "select", "required": true,
"options": { "choices": [
{ "value": "main", "label": "Main menu" },
{ "value": "footer", "label": "Footer" }
] } },
{ "key": "parent", "label": "Sits under", "type": "reference",
"options": { "content_type": "nav-item" },
"help_text": "Leave empty for a top level item." },
{ "key": "position", "label": "Position", "type": "number",
"help_text": "Low numbers first, within its parent." },
{ "key": "description", "label": "Description", "type": "text" },
{ "key": "page", "label": "Links to page", "type": "reference",
"options": { "content_type": "page" } },
{ "key": "external_url", "label": "External link", "type": "url" }
]
}'
A type may reference itself, in the same request that creates it. Nothing has to exist first.
Read the whole menu in one request
curl -G https://api.zevcontent.net/v1/content/nav-item \
--data-urlencode "where[menu]=main" \
--data-urlencode "sort=position" \
--data-urlencode "limit=100" \
-H "Authorization: Bearer $ZEVCONTENT_KEY"
One column of that menu comes back like this:
{
"id": "ent_eJj5XjBmxLzGr4Lqp9pj4",
"label": "Managed IT",
"menu": "main",
"parent": {
"id": "ent_mx5HuZuA7cCJzMC63F4xB",
"type": "nav-item",
"label": "Services",
"menu": "main",
"position": 20
},
"position": 10,
"description": "Keep your systems running, monitored, and supported.",
"page": {
"id": "ent_fa5GHgFEe4Yn5CAfxRZYn",
"type": "page",
"title": "Managed IT",
"slug": "/services/managed-it"
}
}
Both pointers arrive resolved: the parent brings its label, and the page brings its address. There is no second request and no lookup table.
Assembling the tree is the usual few lines:
const res = await fetch(
`${API}/v1/content/nav-item?where[menu]=main&sort=position&limit=100`,
{ headers: { Authorization: `Bearer ${key}` } },
);
const { data: items } = await res.json();
const byId = new Map(items.map((i) => [i.id, { ...i, children: [] }]));
const roots = [];
for (const item of byId.values()) {
const parent = item.parent && byId.get(item.parent.id);
(parent ? parent.children : roots).push(item);
}
Point at the page, not at its address
page is a reference. It stores the entry’s id, and the address is
resolved on every read. Rename the page and the menu follows on the next
request, with nobody editing the menu:
before rename, menu says: /services/managed-it/network
after rename, menu says: /services/managed-it/networks-and-connectivity
Three kinds of destination cover everything a menu does:
| Field type | Points at | Survives a rename |
|---|---|---|
reference | An entry of one of your own types | Yes |
link | A blog article, category, author, or knowledge base page | Yes |
url | Somewhere outside your site | Not ours to resolve |
An item with no destination at all is a heading, which is what the group titles in a mega menu usually are. Leave all three empty and render it as a label.
Before you rename or delete, ask what uses it
curl https://api.zevcontent.net/v1/content/page/ent_fa5.../referenced-by \
-H "Authorization: Bearer $ZEVCONTENT_KEY"
{
"id": "ent_r3kJTK6WZ9KKSqn6G4Cz8",
"type": "nav-item",
"field": "page",
"url": "/v1/content/nav-item/ent_r3kJTK6WZ9KKSqn6G4Cz8"
}
Best practice
Give each item a parent. Do not give a parent a list of children.
It is tempting to model a mega menu as a menu holding columns, each column holding items. Do not. A reference resolves one level, so a menu read that way returns columns whose items are bare ids, and you are back to a request per column.
A parent pointer inverts that. Every item names one thing, one level up, so a single flat listing carries a tree of any depth and you assemble it from ids you already have. This is the whole reason the menu above costs one request instead of four.
Number positions in tens. 10, 20, 30 leaves room to drop something
between two items without renumbering the rest.
One type, many menus. The menu field means the footer, a sidebar and
a mobile menu all live in the same list, filtered apart at read time. Add
a menu by adding a choice, not a content type.
Keep destinations in one type where you can. A reference field targets
exactly one content type, and that target cannot change later. A single
page type with a kind field usually beats separate service-page and
solution-page types, because one page reference then covers the whole
site. Where you genuinely have several types, add one optional reference
field per type and render whichever is set.
Let the labels differ from the titles. A menu says “Pricing” where the
page is titled “Plans and pricing”. Keeping label on the nav item rather
than reading the page title is what lets the menu stay short.
Icons belong in a select. Store the name your frontend maps to a
component, not an image. A select gives the dashboard a fixed list to pick
from, so nobody types arrow-rght and ships a hole in the menu.
What your team sees
Set display_field and subtitle_field on the type and the dashboard
lists items by their label and description rather than by id. help_text
on a field is the sentence under it in the editor, which is where “leave
empty for a top level item” belongs.
Mark the fields your code reads
A slug your routes match on looks exactly like a title on an editing form,
and somebody tidying the title has no way to tell the difference. Set
frontend_contract on it:
{ "key": "slug", "label": "Address", "type": "text", "required": true,
"frontend_contract": true,
"help_text": "Your routes match on this." }
The dashboard then shows that field as a value with a pen rather than an
open input, and asks before handing it over, quoting your help_text.
Nobody loses the ability to edit it. They lose the ability to edit it
without noticing.
Updated at, Sunday, September 20, 2026