Multi-Locale Navigation in Sanity Without Bloating Queries
By Nayan Kyada · · 6 min read
Part of The Sanity + Next.js Guide
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;
// Dynamic field access via a GROQ parameter — not string interpolation.
// `locale` comes from a route param, and TypeScript's union type
// ('en' | 'fr' | 'gu') isn't actually enforced at runtime, so
// interpolating it directly into the query text would be a GROQ
// injection vector. `[$locale]` keeps it as a bound parameter.
const navData = await client.fetch(
`*[_type == "navigation"][0][$locale].links`,
{ locale },
{ next: { revalidate: 3600 } }
);
return (
<html lang={locale}>
<body>
<Nav links={navData} locale={locale} />
{children}
</body>
</html>
);
}The GROQ projection *[_type == "navigation"][0][$locale].links — with locale bound as a query parameter, not interpolated into the query string — 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.
Frequently asked questions
01How do I do dynamic field access in GROQ, like title[$locale]?
Use bracket syntax with a bound parameter — `document[$locale]` — and pass `locale` as a query parameter (the second argument to `client.fetch()`), not by interpolating it into the query string. This is the safe way to select a field whose name is only known at request time; string-interpolating a route param directly into GROQ text is a query-injection risk if that value isn't validated against a strict allowlist.
02Why store all locales in one Sanity navigation document instead of one document per locale?
One query, one revalidation tag, and editors see every language side-by-side in Studio, which surfaces inconsistencies (French nav has 7 links, English has 6) that separate per-locale documents would hide. It only works well when the navigation structure is consistent across locales — for radically different nav trees per locale, separate documents or the document internationalization plugin are simpler.