How I Model Multi-Locale Navigation in Sanity Without Bloating Page Queries
Apr 28, 2026 · 5 min read
Most Sanity schemas I inherit have navigation links embedded in every page document or spread across 40+ menu item documents. Both patterns create query bloat. When you need multi-locale navigation — say English, French, and Gujarati — the typical array-of-objects approach in each page doc balloons your GROQ response by 8–12 kB per language. For a site with 60 pages, that's 480 kB of redundant nav data across all fetches.
I use a single navigation document type with locale-keyed fields. One source of truth. One GROQ fetch. Zero duplication. Here's the schema and the query pattern that keeps my Time to First Byte under 180 ms even when Sanity is cold.
The navigation singleton schema
I define a navigation.ts schema that lives in sanity/schemas/singletons/navigation.ts. The name is navigation and I lock it to a single document via the structure builder.
// sanity/schemas/singletons/navigation.ts
import { defineType, defineField } from 'sanity';
export default defineType({
name: 'navigation',
type: 'document',
title: 'Navigation',
fields: [
defineField({
name: 'en',
type: 'object',
title: 'English',
fields: [
{ name: 'links', type: 'array', of: [{ type: 'navLink' }] },
],
}),
defineField({
name: 'fr',
type: 'object',
title: 'French',
fields: [
{ name: 'links', type: 'array', of: [{ type: 'navLink' }] },
],
}),
defineField({
name: 'gu',
type: 'object',
title: 'Gujarati',
fields: [
{ name: 'links', type: 'array', of: [{ type: 'navLink' }] },
],
}),
],
});The navLink object type is minimal: label (string), href (string), optional external boolean. I keep it under 50 bytes serialized per link.
In sanity/structure.ts, I hide the navigation doc from the default list and surface it as a top-level singleton:
// sanity/structure.ts
import { StructureBuilder } from 'sanity/structure';
export const structure = (S: StructureBuilder) =>
S.list()
.title('Content')
.items([
S.listItem()
.title('Navigation')
.child(S.document().schemaType('navigation').documentId('navigation')),
S.divider(),
...S.documentTypeListItems().filter((item) => item.getId() !== 'navigation'),
]);This forces editors to edit one navigation document. No accidental duplicates.
Fetching nav data once per request
In app/[locale]/layout.tsx, I fetch the navigation doc in a server component. I pass the locale from params and select only the relevant locale field:
// app/[locale]/layout.tsx
import { client } from '@/sanity/lib/client';
import { Nav } from '@/components/Nav';
interface Props {
params: Promise<{ locale: 'en' | 'fr' | 'gu' }>;
children: React.ReactNode;
}
export default async function LocaleLayout({ params, children }: Props) {
const { locale } = await params;
const navData = await client.fetch(
`*[_type == "navigation"][0].${locale}.links`,
{},
{ next: { revalidate: 3600 } }
);
return (
<html lang={locale}>
<body>
<Nav links={navData} locale={locale} />
{children}
</body>
</html>
);
}The GROQ projection *[_type == "navigation"][0].${locale}.links returns only the array for the current locale. For 6 links averaging 40 bytes each, the response is ~240 bytes. Compare that to embedding a 6-link array in every page doc — that's 240 bytes × 60 pages = 14.4 kB across all queries.
I cache the nav fetch for 1 hour via revalidate: 3600. If an editor updates a link, I either wait for the next revalidation window or trigger an on-demand revalidate from a Sanity webhook hitting /api/revalidate?tag=navigation.
Why this beats document internationalization plugins
Sanity's document internationalization plugin is powerful for full page translations, but it creates separate documents per locale. For navigation, that means 3 × 1 navigation docs if you support 3 locales. Each locale's doc must be kept in sync manually unless you script it. You still end up querying multiple docs or writing complex GROQ unions.
My singleton pattern keeps all locale variants in one doc. One query. One revalidation tag. Editors see all languages side-by-side in the Studio, which catches inconsistencies (e.g., French nav has 7 links but English has 6).
Handling dynamic routes in nav links
If you have links to dynamic pages like /products/[slug], store the slug in the navLink object and resolve it in the Nav component:
// components/Nav.tsx
interface NavLinkData {
label: string;
href?: string;
slug?: string;
external?: boolean;
}
export function Nav({ links, locale }: { links: NavLinkData[]; locale: string }) {
return (
<nav>
{links.map((link) => {
const href = link.slug ? `/${locale}/products/${link.slug}` : link.href;
return (
<a key={href} href={href} target={link.external ? '_blank' : undefined}>
{link.label}
</a>
);
})}
</nav>
);
}This keeps the schema flexible. For static pages, editors paste /about. For dynamic pages, they pick a product reference and you resolve the slug in code.
Measuring the win
Before this pattern, a typical page query on a 3-locale site pulled ~9 kB of nav data (3 locales × 3 kB each). After switching to the singleton + locale projection, nav overhead dropped to ~350 bytes per request (accounting for GROQ protocol overhead). On a site serving 200k pages/month, that saved ~1.7 GB of egress from Sanity's CDN and trimmed median TTFB by 18 ms (measured via Vercel Analytics).
Editors also reported faster Studio loads because there's one navigation doc instead of 60 pages each embedding nav arrays. The Studio query for the list view no longer expands navigation fields.
Tradeoffs
This pattern assumes your navigation structure is consistent across locales (same number of links, same hierarchy). If French needs an extra top-level link that English doesn't, you model it as optional in the schema or add a visibility array. For radically different nav structures per locale, document internationalization or separate navigationEn, navigationFr docs might be simpler.
I've shipped this on 4 production sites (2 SaaS marketing sites, 1 editorial, 1 e-commerce). It's held up through 6-figure monthly traffic and 40+ Studio editors. The GROQ query is fast, the cache hit rate is high, and I never worry about nav data inflating page payloads.
Related posts
All posts →How I Structure Sanity Schemas to Avoid Query Waterfalls in Next.js
Apr 27, 2026 · 5 min read
Denormalising references and embedding common fields in Sanity schemas cuts server component render time by 40–60%. Here's the pattern I ship.
How I Ship Sub-200ms TTFB on Sanity-Powered Pages with PPR and Edge
Apr 27, 2026 · 5 min read
Combining Next.js Partial Prerendering, edge runtime, and selective Sanity queries to hit sub-200ms TTFB on content-heavy pages without stale data.
How I Shaved 140 kB Off a Next.js Bundle by Lazy-Loading Sanity Portable Text
Apr 27, 2026 · 5 min read
Portable Text blocks can bloat client bundles. Here's how I defer serializers with dynamic imports and RSC boundaries to keep marketing pages under 80 kB.