Scalable navigation patterns
Build scalable navigation in Sanity—from basic menus to mega menus—with best practices for structure and dynamic links.
Designing a navigation in a structured content system like Sanity is different from what you might be used to in a monolithic content management system (CMS). Instead of dragging menu items around in a theme editor, you're building a flexible, scalable content model that supports both the site today and what it might grow into. This guide walks you through one approach to structuring navigation in Sanity, whether you're working with straightforward links or building out a more complex menu.
Prerequisites
- A studio with document types for the navigation to link to. The examples reference
page,product, andblogPost; see Schema to define your own.
Why model navigation in Sanity?
When you use Sanity to manage navigation, you get a few key benefits:
- Structured content: Your links are data. You can reference them, reuse them, and change how they're rendered without changing the data.
- Editor-friendly: Once set up, editors can manage the navigation without asking developers for help.
- Keeps things in sync: If you reference a page in the nav, and the slug changes, it updates everywhere—automatically. No more broken links.
Model a basic navigation menu
Here's a lightweight schema for a basic navigation menu. This example assumes your studio already has page, product, and blogPost document types; adjust the to array to match the types in your schema. It supports:
- Internal links (references to pages)
- External links (plain URLs)
Start with the navigation item. Defining it as a standalone type, rather than inline in the navbar's array, lets you register it once and reference it by name from the navbar, a footer, or anywhere else the site needs links:
import { defineType, defineField, defineArrayMember } from 'sanity'
export const navItem = defineType({
name: 'navItem',
title: 'Navigation Item',
type: 'object',
fields: [
defineField({
name: 'label',
type: 'string',
title: 'Label',
}),
defineField({
name: 'link',
type: 'array',
validation: (rule) => rule.max(1).required(),
of: [
defineArrayMember({
name: 'internalLink',
type: 'object',
title: 'Internal Link',
fields: [
defineField({
name: 'internalReference',
type: 'reference',
to: [
{ type: 'page' },
{ type: 'product' },
{ type: 'blogPost' }
],
}),
],
}),
defineArrayMember({
name: 'externalLink',
type: 'object',
title: 'External Link',
fields: [
defineField({ name: 'url', type: 'url' }),
],
}),
],
}),
defineField({
name: 'openInNewTab',
type: 'boolean',
initialValue: false,
}),
],
})Then define the navbar document that holds an array of those items:
import { defineType, defineField, defineArrayMember } from 'sanity'
export const navbar = defineType({
name: 'navbar',
title: 'Site Navigation',
type: 'document',
fields: [
defineField({
name: 'title',
type: 'string',
title: 'Navigation Title',
}),
defineField({
name: 'items',
type: 'array',
title: 'Links',
of: [defineArrayMember({ type: 'navItem' })],
}),
],
})This model lets editors manage links, and gives you the data shape you need on the frontend.
The result looks like this:
Group links with a mega menu
A mega menu is a large dropdown that displays multiple columns of organized links instead of a flat list — the wide navigation dropdowns you see on e-commerce and software-as-a-service (SaaS) sites, with sections such as Resources, Products, and Company.
Here's an add-on model that enables grouped links:
import { defineType, defineField, defineArrayMember } from 'sanity'
export const megaMenuColumn = defineType({
name: 'megaMenuColumn',
title: 'Mega Menu Column',
type: 'object',
fields: [
defineField({
name: 'heading',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'items',
type: 'array',
title: 'Links',
of: [
defineArrayMember({
type: 'object',
name: 'columnLink',
fields: [
defineField({
name: 'linkLabel',
type: 'string',
title: 'Link Label',
}),
defineField({
name: 'link',
type: 'array',
validation: (rule) => rule.max(1).required(),
of: [
defineArrayMember({
name: 'internalLink',
type: 'object',
title: 'Internal Link',
fields: [
defineField({
name: 'internalReference',
type: 'reference',
to: [
{ type: 'page' },
{ type: 'product' },
{ type: 'blogPost' }
],
}),
],
}),
defineArrayMember({
name: 'externalLink',
type: 'object',
title: 'External Link',
fields: [
defineField({ name: 'url', type: 'url' }),
],
}),
],
}),
defineField({
name: 'openInNewTab',
type: 'boolean',
initialValue: false,
}),
],
}),
],
}),
],
preview: {
select: {title: 'heading'},
},
})The Sanity Studio interface looks like this:
Embed megaMenuColumn in a top-level navbar document like this:
import { defineType, defineField, defineArrayMember } from 'sanity'
export const navbar = defineType({
name: 'navbar',
title: 'Site Navigation',
type: 'document',
fields: [
defineField({
name: 'title',
type: 'string',
title: 'Navigation Title',
}),
defineField({
name: 'items',
type: 'array',
title: 'Links',
of: [
defineArrayMember({ type: 'navItem' }),
defineArrayMember({ type: 'megaMenuColumn' }),
],
}),
],
})A navigation can now mix single links and grouped mega menu columns in one flexible array.
Register the schema types
Sanity resolves defineArrayMember({ type: 'navItem' }) by name, and that name exists only if the type is registered in your Studio config. Add navItem and megaMenuColumn alongside the navbar document:
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { navItem } from './schemaTypes/nav-item'
import { megaMenuColumn } from './schemaTypes/nav-column'
import { navbar } from './schemaTypes/navbar'
export default defineConfig({
name: 'default',
title: 'My Studio',
projectId: 'YOUR_PROJECT_ID',
dataset: 'production',
plugins: [structureTool()],
schema: {
types: [navItem, megaMenuColumn, navbar],
},
})Leave one out and the Studio fails to load with Unknown type: navItem.
Query the navigation
Navigation items store a reference, not a URL. Dereferencing it with -> at query time is what makes the model pay off: change a page's slug and every navigation item pointing at it resolves to the new path, with no edit to the navbar document.
This query fetches one navbar by title and flattens both item types into the shape a frontend renders:
*[_type == "navbar" && title == $navTitle][0]{
title,
items[]{
_type,
_type == "navItem" => {
label,
openInNewTab,
"href": select(
link[0]._type == "internalLink" => "/" + link[0].internalReference->slug.current,
link[0]._type == "externalLink" => link[0].url
)
},
_type == "megaMenuColumn" => {
heading,
items[]{
linkLabel,
openInNewTab,
"href": select(
link[0]._type == "internalLink" => "/" + link[0].internalReference->slug.current,
link[0]._type == "externalLink" => link[0].url
)
}
}
}
}{
"title": "Main navigation",
"items": [
{
"_type": "navItem",
"label": "About",
"openInNewTab": false,
"href": "/about"
},
{
"_type": "navItem",
"label": "Community",
"openInNewTab": true,
"href": "https://slack.sanity.io"
},
{
"_type": "megaMenuColumn",
"heading": "Products",
"items": [
{
"linkLabel": "Studio",
"openInNewTab": false,
"href": "/products/studio"
}
]
}
]
}Pass the title as a parameter — {navTitle: 'Main navigation'} — rather than interpolating it into the query string. To generate TypeScript types for the result, see Sanity TypeGen.
Best practices
Use references whenever possible
Referencing internal documents like page keeps your links in sync when slugs change. Let your data be the source of truth.
Model the content, not the UI
Your schema should reflect the structure of the content itself, not how it's currently rendered in your frontend. This keeps your data flexible and portable, so if your design or framework changes, you're not stuck with a schema built around a previous layout.
Make it editor-friendly
Give fields clear names and descriptions. Use previews so editors can see what they're editing at a glance.
Reuse structures
Objects like navItem or megaMenuColumn can be reused in footers, mobile navigation, or anywhere else you need links.
Next steps
Navigation is one of the most important systems on your site. With Sanity you can make it flexible for developers and friendly for editors.
To take the model further:
- Add localization support for translated navigation
- Add call-to-action buttons, featured items, or media
- Add a custom preview to the
navItemobject innav-item.ts
preview: {
select: {title: 'label', linkType: 'link.0._type'},
prepare({title, linkType}) {
return {
title,
subtitle: linkType === 'internalLink' ? 'Internal link' : 'External link',
}
},
},Editors see this preview:
Start small, keep it structured, and grow the model as the site does.


