How to Model Navigation Menus in a Headless CMS (and Render in Next.js)

Model header and footer navigation in a headless CMS so editors can update menus without deployments, and render them safely in Next.js with caching and type-safe links.

R
Raşit Apalak
4 min read

Navigation is “small content” that changes often—and it’s one of the best candidates to move into your headless CMS. The goal is simple: editors should be able to update header/footer links without touching code, while developers keep the UI consistent and safe.

This guide shows a practical menu model and a Next.js rendering approach that works for multi-language, multi-site, and complex dropdowns.


Table of Contents


Most sites need 3 “menu surfaces”:

  • Header navigation (primary)
  • Footer navigation (often multiple columns)
  • Utility menu (login, docs, status, contact)

Model these as separate menus so you can change them independently and reuse them across pages.


Keep menus explicit and predictable:

Menu

  • name (e.g. “Main”, “Footer”, “Docs”)
  • slug (e.g. main, footer, docs)
  • items[] (ordered)

MenuItem

  • label (string)
  • href (string)
  • kind (enum): internal | external
  • newTab (boolean)
  • children[] (optional, ordered)
  • icon (optional)
  • trackingId (optional)

This structure is flexible enough for dropdowns, footer columns, and utility menus—without turning navigation into a CMS-freeform rich text blob.


Handling nested menus (dropdowns)

Two levels is usually enough:

  • Parent item (e.g. “Solutions”)
  • Children items (e.g. “For Agencies”, “For SaaS”, “For E-commerce”)

If you need deeper nesting, consider:

  • limiting depth to 3 max
  • adding a “mega menu” section type rather than infinite recursion

For footer columns, you can treat “column” as a parent item with children.


Avoid “magic string” URLs that editors can break. Two common approaches:

Option A (simplest): store href as a string

  • Editors paste /blog, /docs, https://…
  • Developers validate at runtime (recommended)

Option B (safer): internal link references

Store one of:

  • internalPath (string like /blog/[slug])
  • or a reference to a CMS entry (Page, Post, Category)

Then your frontend resolves it to a stable route. This is great for big sites but takes more implementation effort.


Multi-language menus

If you have locales, pick one of these patterns:

Pattern 1: separate menu per locale

  • main-en, main-de, main-tr

Pros: easy editorial control.
Cons: duplication across locales.

Pattern 2: one menu with localized labels

  • label becomes label_en, label_de, …
  • or a label object keyed by locale

Pros: single menu structure.
Cons: CMS UI can be less friendly if localization support is limited.

Whichever you choose, be consistent with your site routing (e.g. /en/... vs domain per locale).


Next.js rendering pattern

The cleanest pattern in Next.js App Router:

  • fetch menu data in a server component (Header/Footer)
  • render using next/link for internal links
  • ensure tokens stay server-side (don’t fetch from the browser)

Pseudo-code:

import Link from 'next/link';

function NavItem({ item }) {
  const isExternal = item.kind === 'external' || item.href.startsWith('http');
  if (isExternal) {
    return (
      <a href={item.href} target={item.newTab ? '_blank' : '_self'} rel="noreferrer">
        {item.label}
      </a>
    );
  }
  return <Link href={item.href}>{item.label}</Link>;
}

For dropdowns:

  • render parent as button/link
  • render children in a popover/menu
  • always handle the case where children is empty

Caching and invalidation

Menus are perfect for caching:

  • Cache aggressively (they change rarely compared to page views)
  • Invalidate on publish using webhooks (best) or short TTL

Recommended:

  • Cache menu fetches on the server (framework cache / fetch cache)
  • Invalidate when content changes (e.g. “menu updated” webhook)

If you’re already using ISR or tag-based revalidation in Next.js, you can tie menu updates to a single “navigation” tag.


Pitfalls to avoid

  • Unvalidated URLs: add a simple runtime validator to prevent javascript: or malformed URLs.
  • Editor can break the site: keep a fallback menu in code or render a safe default when menu fetch fails.
  • Over-modeling: don’t try to represent every layout pixel in navigation; keep it data-first.
  • No ownership: decide who owns navigation changes and add a review step if needed.

Related posts:

Share this post:

Related posts