Docs
Dashboard

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 typePoints atSurvives a rename
referenceAn entry of one of your own typesYes
linkA blog article, category, author, or knowledge base pageYes
urlSomewhere outside your siteNot 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