This starter treats languages as CMS configuration, not hard-coded locale files. Editors manage siteLanguageSettings in Sanity; the Next.js app reads that singleton for URL prefixes, <html lang>, translation fallback order, Studio field tabs, sitemap alternates, and hreflang. On main, translations live inside one document per page or module via sanity-plugin-internationalized-array — language tabs on each translatable field. A parallel branch (variant/document-level) uses one document per language instead; PR #62 compares both. There is no next-intl dependency — routing is App Router [locale] + a lightweight proxy.ts.
Two strategies (pick your branch)
Field-level (main) — internationalizedArray
- One Sanity document holds all languages; each translatable field shows tabs (
en,de, …). - Same slug and module stack across locales; only field values differ.
- Best when layouts match and copy is the main variable (marketing pages, UI strings in modules).
- Plugin types in schema:
internationalizedArrayString,internationalizedArrayRichText,internationalizedArrayRichTextMedia, etc.
Document-level (variant/document-level) — @sanity/document-internationalization
- Separate document per language — different slugs, modules, or SEO per locale allowed.
- GROQ matches locale at query time; no runtime field resolver.
- Best when locales diverge structurally (not just translation).
- See PR #62 for side-by-side decision guide; neither branch is a downgrade.
Source of truth: siteLanguageSettings
Settings → Site languages singleton in Studio.
availableLanguages— ordered list (id+title). Order = translation fallback order when a field has no value for the requested locale.defaultLanguageId— default locale; its URLs have no/{locale}/prefix on the public site.- First create — schema
initialValuepre-fillsen+de, defaulten; editors can change before publish. - Publish required — web reads from CDN; use
SANITY_API_READ_TOKENlocally if settings are still draft-only. - Studio tabs —
internationalizedArrayplugin loads language list live from Sanity on each Studio load (internationalizedArrayLanguagesFromClient); no Studio rebuild when languages change.
Code fallbacks (edge cases only)
- When the singleton is missing, invalid, or unpublished: web uses minimal
enonly (fallbackSiteLocales.ts); Studio plugin uses matchingFALLBACK_LANGUAGES. Keep both constants in sync. - Normal operation always uses published
siteLanguageSettings.
URL routing & canonical URLs
App Router: all pages under app/[locale]/….
- Default locale — public URLs unprefixed:
/,/about,/projects/acme. - Other locales — prefixed:
/de/about,/de/projects/acme. proxy.ts— fetches locale list from Sanity CDN (cached); internally rewrites/about→/{defaultLocale}/about; redirects/{defaultLocale}/about→/about(canonical unprefixed form).LanguageContext— client navigation vialocalePathso links stay in the active language.- Reserved segments — every non-default language
idis a first path segment; do not use those ids as page slugs on the default-locale site. - No
next-intl— locale comes from route params + Sanity config, not JSON message catalogs.
Examples
- Home EN:
/· Home DE:/de - Page EN:
/about· Page DE:/de/about - Project EN:
/projects/acme· Project DE:/de/projects/acme
Resolving content for the active locale
URL locale (params.locale) drives which translation renders.
Helpers in sanity/utils/sanityLocalizedText.ts (pass optional siteLocale from fetchSiteLanguageSettings() for correct fallback order):
parseLocalizedText({ entries, locale?, as? })— single entry point;as: "auto" | "string" | "blocks".pickLocalizedString(entries, locale, siteLocale?)— React-friendly string pick.pickLocalizedPortableTextBlocks(entries, locale, siteLocale?)— blocks forRichTextMedia.
Fallback order per field
- Exact tag (e.g.
de-DE) → base tag (de) → otheravailableLanguagesin CMS order → any entry with content.
Where locale flows
fetchSiteLanguageSettings()→[locale]/layout.tsx→LanguageProvider,ModulesRenderer,generateStaticParams, metadata, footer path utils.- Module titles, text bodies, Content Refs headings, SEO fields — all use the same resolution rules.
Studio editor workflow
- Open Settings → Site languages — add/remove languages, set default, publish.
- Edit any document with
internationalizedArray*fields — switch tabs per field (not per document onmain). - Navigation, Error page, Cookie banner copy — also i18n fields where defined.
- Presentation — preview per locale using the same URL rules as production (
/{slug}vs/de/{slug}). - Changing languages updates the live site on next fetch; Studio field tabs update on reload.
Plugin registration (sanity.config.ts)
internationalizedArray({ languages: …, fieldTypes: [string, richText, richTextMedia, …] }).- Add new custom translatable types to
fieldTypeswhen you extend schema.
SEO & discoverability
resolveSanityMetadata— canonical URL +hreflangalternates per route from Sanity SEO fields + locale config.sitemap.ts— entries per locale withalternatesandx-defaultpointing at the default locale URL.robots.ts— staging-aware.- Localized titles/descriptions come from the same i18n fields as on-page content — one source of truth.
UI chrome & defaults
- Language switch — label “Language”; switches between configured locales while preserving path.
- Cookie banner — optional; when CMS doc is empty,
en/deJSON defaults ship in code; categories and copy editable per language in Settings. - Static bootstrap — root
<html lang>uses fallback until client sync; hydrated layout uses CMS default.
What this starter does not do
- No gettext / PO files or
next-intlmessage bundles for page copy — marketing text lives in Sanity. - No automatic machine translation — editors fill each language tab (or duplicate documents on document-level branch).
- Locale ids are lowercase path segments — plan slug collisions with language ids upfront.
Quick checklist for adopters
- Publish
siteLanguageSettingsbefore expecting DE URLs. - Fork
mainorvariant/document-levelbefore customizing schema. - Pass
siteLocaleinto pick helpers anywhere fallback order must match CMS. - Align
SANITY_API_READ_TOKENon web + studio for draft language settings in dev. - Read PR #62 if unsure which i18n model fits the project.