---
title: Navigation and mega menus
description: 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.

```bash
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.

> **INFO: Depth is not a setting**
>
> There is no maximum. A bar, a column and the links inside it is three
> levels; add a fourth by giving an item a parent that already has one.

## Read the whole menu in one request

```bash
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:

```json
{
  "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:

```js
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

```bash
curl https://api.zevcontent.net/v1/content/page/ent_fa5.../referenced-by \
  -H "Authorization: Bearer $ZEVCONTENT_KEY"
```

```json
{
  "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:

```json
{ "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.

> **INFO: The one value that never changes**
>
> Every entry also has an `ent_…` id, issued once and never editable, shown
> on the entry in the dashboard. Where you want a pretty URL **and** a stable
> target, route on the slug and pin anything internal to the id.