Multi-Locale Navigation in Sanity Without Bloating Queries

By Nayan Kyada · · 5 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 means every page query carries every language's nav links, and every page document has to be edited when a link changes. On a site with dozens of pages, that's a lot 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.

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, tags: ['navigation'] } }
  );
 
  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 and tag it navigation. If an editor updates a link, I either wait for the next revalidation window or have a Sanity webhook hit a route handler (e.g. /api/revalidate?tag=navigation) that calls revalidateTag('navigation', 'max').

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

The win is easy to reason about. If every page document embeds nav links for all three locales, every page query drags all of them along. With the singleton + locale projection, the layout fetches only the current locale's links — a few hundred bytes for a typical menu — once, cached, and tagged for revalidation. Page queries stop carrying nav data at all, so they get smaller and simpler, and you measure any TTFB or bandwidth change on your own site rather than assuming one.

Editors also get a simpler Studio: one navigation document instead of nav arrays scattered across every page.

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.

The pattern works well for marketing, editorial, and e-commerce sites alike: the GROQ query is small, it caches well, and nav data never inflates page payloads.

Frequently asked questions

How 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.

Why 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.