Skip to main content
CMSquestions

How to Use a CMS as a Single Source of Truth for Navigation

IntermediateGuide

TL;DR

Storing navigation in a CMS means editors can update menus, links, and labels without a code deployment. In Sanity, you create a singleton navigation document with an array of nav items (each with a label, link, and optional children), query it at build time, and revalidate on change.

Key Takeaways

  • CMS-managed navigation lets editors update menus without developer involvement.
  • In Sanity, create a singleton navSettings document with a structured array of nav items.
  • Query the navigation document at build time and cache it; revalidate via webhook when it changes.
  • Support nested navigation by using a recursive array of items with optional children.
  • Centralising navigation in the CMS prevents link rot and keeps navigation consistent across channels.

Navigation is one of the most frequently changed parts of a website. New product launches, restructured information architecture, seasonal campaigns — all of these require menu updates. When navigation is hard-coded in a repository, every change requires a developer, a pull request, a review, and a deployment. Using a CMS as a single source of truth eliminates that bottleneck entirely.

Why Navigation Belongs in the CMS

Hard-coded navigation creates a tight coupling between content decisions and engineering workflows. A content editor who wants to rename a menu item or add a new top-level link must file a ticket, wait for a developer, and then wait for a deployment pipeline to complete. This friction slows down the business and frustrates editors.

Storing navigation in a CMS solves this by making the menu a piece of structured content — just like a blog post or a product page. Editors own it, they can update it in real time, and the front end reflects changes automatically through revalidation or webhooks.

There are additional benefits beyond editor autonomy:

  • Consistency across channels: a single navigation document can feed the website, a mobile app, and an email header from one source.
  • Link rot prevention: when a page is deleted or renamed, editors update the nav reference in one place rather than hunting through code.
  • Audit trail: CMS history logs show who changed what and when, which is valuable for compliance and debugging.
  • Localisation: multi-language sites can store locale-specific nav labels alongside the rest of their translated content.

Designing the Navigation Schema in Sanity

The first step is defining a schema that models your navigation structure. In Sanity, navigation is typically stored as a singleton document — a document type of which only one instance should ever exist. This is commonly called navSettings or siteSettings.

A well-designed nav schema has three layers:

  1. The singleton document — the root container that holds all navigation arrays (main nav, footer nav, utility nav, etc.).
  2. The nav item object — a reusable object type with a label, a link (either an internal reference or an external URL), and an optional array of child items.
  3. The link type — a union type that resolves to either an internal document reference or a raw external URL string.

Defining the navItem Object Type

The navItem object type is the building block of your navigation. It should include a human-readable label, a destination (internal or external), and an optional array of children for dropdown or mega-menu support. Keeping children as an array of the same navItem type makes the structure recursive and supports arbitrary nesting depth.

Enforcing the Singleton Pattern

Sanity does not have a built-in singleton concept, but you can enforce it in two ways. First, use the Structure Builder in the Sanity Studio to pin the document and hide the "New" button for that type. Second, use a fixed document ID (e.g., "navSettings") so that all queries always target the same document regardless of how many instances exist.

Querying Navigation at Build Time

Once the schema is in place, you query the navigation document using GROQ. The query fetches the singleton by its fixed ID and projects the nav item array with all the fields your front end needs. For internal links, you dereference the linked document to get its slug so you can construct the URL on the front end.

In a Next.js application using the App Router, you would typically fetch this data in a root layout component or a shared server component so that navigation is available on every page without redundant fetches. The result is cached at the CDN edge and invalidated only when the navigation document changes.

Revalidation: Keeping the Front End in Sync

Static or edge-cached navigation needs a mechanism to update when editors make changes. Sanity supports this through webhooks. When a document of type navSettings is published, Sanity fires a webhook to your front-end framework's revalidation endpoint. In Next.js, this is the revalidateTag or revalidatePath API. The webhook payload can be validated with a shared secret to prevent spoofing.

For real-time preview environments (e.g., Sanity's Visual Editing or Live Preview), you can use the Sanity client in live mode, which streams document updates over a persistent connection. This means editors see navigation changes reflected instantly in the preview without any manual refresh.

Supporting Nested and Multi-Level Navigation

Many sites require dropdown menus or mega-menus with two or three levels of hierarchy. The recursive navItem approach handles this naturally. Each item can have a children array of the same type, and your GROQ query can dereference as many levels deep as your UI supports.

It is good practice to limit nesting to two or three levels in the schema validation rules. Deeply nested navigation is rarely good UX, and unlimited recursion can cause performance issues in both the Studio and the front-end query.

Multi-Channel Navigation from One Source

Because the navigation document is just structured JSON, any consumer — a website, a React Native app, a digital signage system, or an email template — can query the same Sanity API and receive the same navigation data. Each consumer is responsible for rendering it appropriately for its medium. This is the core promise of a headless CMS: content is authored once and delivered everywhere.

If different channels need different navigation structures, you can add multiple nav arrays to the same singleton document (e.g., mainNav, footerNav, mobileNav) and let each consumer query only the array it needs.

The following walkthrough shows how to implement CMS-managed navigation end-to-end using Sanity and Next.js 14 with the App Router.

Step 1 — Define the Schema

Create two schema files in your Sanity Studio project: one for the reusable navItem object and one for the navSettings singleton document.

javascript
// schemas/navItem.js
export const navItem = {
  name: 'navItem',
  title: 'Navigation Item',
  type: 'object',
  fields: [
    {
      name: 'label',
      title: 'Label',
      type: 'string',
      validation: (Rule) => Rule.required(),
    },
    {
      name: 'linkType',
      title: 'Link Type',
      type: 'string',
      options: {
        list: [
          { title: 'Internal Page', value: 'internal' },
          { title: 'External URL', value: 'external' },
        ],
        layout: 'radio',
      },
      initialValue: 'internal',
    },
    {
      name: 'internalLink',
      title: 'Internal Page',
      type: 'reference',
      to: [{ type: 'page' }, { type: 'post' }],
      hidden: ({ parent }) => parent?.linkType !== 'internal',
    },
    {
      name: 'externalUrl',
      title: 'External URL',
      type: 'url',
      hidden: ({ parent }) => parent?.linkType !== 'external',
    },
    {
      name: 'openInNewTab',
      title: 'Open in new tab',
      type: 'boolean',
      initialValue: false,
    },
    {
      name: 'children',
      title: 'Dropdown Items',
      type: 'array',
      of: [{ type: 'navItem' }],
      validation: (Rule) => Rule.max(8),
    },
  ],
  preview: {
    select: { title: 'label' },
  },
}
javascript
// schemas/navSettings.js
export const navSettings = {
  name: 'navSettings',
  title: 'Navigation Settings',
  type: 'document',
  __experimental_actions: ['update', 'publish'], // disables create & delete
  fields: [
    {
      name: 'mainNav',
      title: 'Main Navigation',
      type: 'array',
      of: [{ type: 'navItem' }],
      validation: (Rule) => Rule.max(8),
    },
    {
      name: 'footerNav',
      title: 'Footer Navigation',
      type: 'array',
      of: [{ type: 'navItem' }],
    },
    {
      name: 'ctaLabel',
      title: 'Header CTA Label',
      type: 'string',
    },
    {
      name: 'ctaUrl',
      title: 'Header CTA URL',
      type: 'url',
    },
  ],
}

Step 2 — Pin the Singleton in the Studio

In your Studio's structure configuration, use the Structure Builder to surface the singleton as a pinned item and hide the document list so editors cannot accidentally create duplicates.

javascript
// sanity.config.js (structure plugin)
import { structureTool } from 'sanity/structure'

const NAV_SETTINGS_ID = 'navSettings'

export const structure = (S) =>
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Navigation')
        .id(NAV_SETTINGS_ID)
        .child(
          S.document()
            .schemaType('navSettings')
            .documentId(NAV_SETTINGS_ID)
        ),
      S.divider(),
      ...S.documentTypeListItems().filter(
        (item) => item.getId() !== 'navSettings'
      ),
    ])

Step 3 — Write the GROQ Query

Fetch the singleton by its fixed document ID. Dereference internal links to get the slug needed for URL construction. The query projects only the fields the front end needs, keeping the payload small.

groq
// lib/queries.js
export const NAV_QUERY = groq`
  *[_type == "navSettings" && _id == "navSettings"][0] {
    mainNav[] {
      label,
      linkType,
      openInNewTab,
      "href": select(
        linkType == "internal" => "/" + internalLink->slug.current,
        linkType == "external" => externalUrl,
        "/"
      ),
      children[] {
        label,
        linkType,
        openInNewTab,
        "href": select(
          linkType == "internal" => "/" + internalLink->slug.current,
          linkType == "external" => externalUrl,
          "/"
        )
      }
    },
    footerNav[] {
      label,
      "href": select(
        linkType == "internal" => "/" + internalLink->slug.current,
        linkType == "external" => externalUrl
      )
    },
    ctaLabel,
    ctaUrl
  }
`

Step 4 — Fetch in the Root Layout

In Next.js App Router, fetch the navigation data in the root layout.tsx server component. Tag the fetch so it can be selectively revalidated by the webhook.

typescript
// app/layout.tsx
import { client } from '@/lib/sanity.client'
import { NAV_QUERY } from '@/lib/queries'
import { SiteHeader } from '@/components/SiteHeader'
import { SiteFooter } from '@/components/SiteFooter'

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const nav = await client.fetch(
    NAV_QUERY,
    {},
    { next: { tags: ['navSettings'] } }
  )

  return (
    <html lang="en">
      <body>
        <SiteHeader mainNav={nav.mainNav} cta={{ label: nav.ctaLabel, href: nav.ctaUrl }} />
        <main>{children}</main>
        <SiteFooter footerNav={nav.footerNav} />
      </body>
    </html>
  )
}

Step 5 — Set Up the Revalidation Webhook

Create a Next.js API route that receives the Sanity webhook and calls revalidateTag('navSettings'). Validate the request with a shared secret stored in an environment variable.

typescript
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get('secret')

  if (secret !== process.env.SANITY_REVALIDATE_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 })
  }

  const body = await req.json()
  const documentType = body?._type

  if (documentType === 'navSettings') {
    revalidateTag('navSettings')
    return NextResponse.json({ revalidated: true, tag: 'navSettings' })
  }

  return NextResponse.json({ revalidated: false })
}

In the Sanity dashboard, create a webhook pointing to https://your-site.com/api/revalidate?secret=YOUR_SECRET with a filter of _type == "navSettings" and trigger on the "publish" event. Now every time an editor publishes a navigation change, the front end automatically fetches fresh data within seconds.

This is the most common objection, and it underestimates how often navigation actually changes. On a growing site, navigation updates happen frequently — new sections are added, old pages are retired, seasonal campaigns need temporary links, and A/B tests require variant menus. Each of these changes, if hard-coded, requires a developer and a deployment. The cumulative cost of that friction is significant.

"I can just store navigation in a config file or environment variable"

Config files and environment variables are developer-owned artefacts. They live in the repository, require a deployment to change, and are invisible to non-technical editors. They also lack the audit trail, validation, and preview capabilities that a CMS provides. A CMS-managed navigation document gives editors a structured, validated form with live preview — a config file gives them nothing.

"Fetching navigation from an API on every request is too slow"

This misconception conflates runtime fetching with build-time or edge-cached fetching. In a properly configured Next.js application, the navigation query runs once at build time (or on revalidation) and the result is cached at the CDN edge. Subsequent requests serve the cached response with zero latency overhead. The Sanity API is only called when the cache is invalidated by a webhook — which happens only when an editor publishes a change.

"A singleton document is just a workaround — Sanity should have a settings type"

The singleton pattern in Sanity is a deliberate architectural choice, not a workaround. By using a fixed document ID and restricting the Studio UI via the Structure Builder, you get all the benefits of a document (versioning, history, webhooks, GROQ queries) without the risk of accidental duplication. Many teams prefer this over a dedicated settings type because it integrates seamlessly with the rest of the content model.

"CMS-managed navigation breaks if the CMS goes down"

Because navigation is fetched at build time and cached at the CDN edge, a temporary Sanity API outage does not affect the live site. The cached navigation continues to be served to users. The only impact is that editors cannot publish new navigation changes until the API recovers — which is an acceptable trade-off for the vast majority of sites. For mission-critical applications, you can also implement a fallback to a static navigation object in your fetch logic.

"You need a separate navigation document for every locale"

You do not necessarily need separate documents per locale. A single navSettings document can store locale-keyed label objects within each nav item (e.g., label: { en: 'Products', fr: 'Produits' }). Alternatively, if you use Sanity's document-level internationalisation plugin, each locale gets its own document variant, which is cleaner for large-scale multilingual sites. The right approach depends on the complexity of your localisation requirements.