
Multi-Locale Next.js Sites With a Headless CMS: What You Actually Need
TL;DR: Building multi-locale Next.js sites with a headless CMS is often overcomplicated and overarchitected. In this article we explain how to leverage existing CMS tooling, routing and locale-aware SEO elements to elegantly scale localization.
We recently wrapped up a Next.js + Storyblok build that ships in 14 locales. Before we started, the team did what most teams do: looked at the npm ecosystem, found half a dozen i18n libraries with thousands of stars, and started weighing tradeoffs.
A few weeks in, we realized that most people overthink it. When your content lives in a headless CMS, most of what those libraries promise is solving a problem you don't actually have. This post is about what you genuinely need to ship a multi-locale Next.js site when the CMS is doing the heavy lifting, plus a few things that quietly bite you if you skip them.
The mental model: each locale is just a different page
Here's the reframe that makes everything else click.
When you fetch content from a CMS by slug and locale, the locale isn't really "translating" anything in your codebase. It's changing which document gets returned. As far as Next.js is concerned, /en-ca/products/widget and /fr-ca/products/widget are two completely different pages with two completely different responses from the CMS.
That means your React components don't need to know about translations at all. They render whatever the CMS gave them. The URL prefix decides which document to fetch, and the document is already in the right language because someone on the editorial team wrote it that way.
The only thing in your codebase that genuinely needs to be language-aware is plaintext UI strings. Things the CMS doesn't manage: "Read more", "Submit", "Loading…", form validation messages, "Page X of Y". For those, a small JSON dictionary keyed by locale is enough.
You don't need a library.
1 2 3 4 5 6 7 8 9 10// lib/dictionary.ts const dictionary = { 'en-ca': { readMore: 'Read more', submit: 'Submit' }, 'fr-ca': { readMore: 'En savoir plus', submit: 'Envoyer' }, // ...12 more } as const export function getDictionary(locale: keyof typeof dictionary) { return dictionary[locale] ?? dictionary['en-ca'] }
That's the entire dictionary layer. No provider, no hook, no async loader. Server components import it directly.
The folder structure handles the routing
Next.js App Router you set [locale] as a dynamic segment, and that's basically the routing done:
1 2 3 4 5 6app/ [locale]/ layout.tsx page.tsx [...slug]/ page.tsx
In generateStaticParams you return the supported locales. In layout.tsx you validate the incoming locale and call notFound() if it isn't supported. That's it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23// app/[locale]/layout.tsx const LOCALES = ['en-ca','fr-ca','en-us','de-de', /* ...10 more */] as const export function generateStaticParams() { return LOCALES.map((locale) => ({ locale })) } export default async function LocaleLayout({ children, params, }: { children: React.ReactNode params: Promise<{ locale: string }> }) { const { locale } = await params if (!LOCALES.includes(locale as any)) notFound() return ( <html lang={locale}> <body>{children}</body> </html> ) }
Notice the lang attribute on the <html> tag. This is the first thing people forget and it matters more than you'd guess. Screen readers use it to pick a voice. Browsers use it for spellcheck and hyphenation. Google uses it as a signal alongside hreflang. If every page on your site claims to be English while half of it is German, you've quietly broken accessibility and SEO at the same time. One attribute, real consequences.
Sitemap alternates: the part everyone underdoes
If you're serving the same logical page in 14 locales, search engines need to know those pages are alternates of each other. The cleanest place to declare that is your XML sitemap with xhtml:link entries.
For every URL, you list every alternate (including a self-reference), plus an x-default. Skip the self-reference and Google may decide your hreflang annotations are inconsistent and ignore them entirely.
1 2 3 4 5 6 7 8 9 10 11 12<url> <loc>https://example.com/en-ca/products/widget</loc> <xhtml:link rel="alternate" hreflang="en-ca" href="https://example.com/en-ca/products/widget" /> <xhtml:link rel="alternate" hreflang="fr-ca" href="https://example.com/fr-ca/products/widget" /> <xhtml:link rel="alternate" hreflang="de-de" href="https://example.com/de-de/products/widget" /> <!-- ...12 more --> <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/en-ca/products/widget" /> </url>
A few things that catch people out:
Every alternate URL has to return a 200. A 301 or 404 anywhere in the set and the whole annotation can get dropped.
Alternates have to be reciprocal. If page A lists page B, page B has to list page A.
Use absolute URLs, not relative paths.
Match the ISO codes correctly. It's
en-CA, noten_caoren-can.Don't forget self-references in each
<url>block.
If you're generating the sitemap from CMS content (and you should be), validate the output as part of CI.
The rest of the locale-aware checklist
Beyond lang and the sitemap, here's the short list of things that have to be locale-aware in code:
Canonical URLs. Each locale variant gets its own canonical pointing at itself. Don't canonicalise everything to the English version. That's telling search engines to ignore your other locales.
Open Graph tags. Set
og:localeandog:locale:alternateso social previews use the right language.Date, number, and currency formatting. Use
Intl.DateTimeFormat,Intl.NumberFormat, andIntl.NumberFormatwithstyle: 'currency'. Native browser APIs, no dependency needed.The language switcher. When a user on
/de-de/products/widgetswitches to French, they should land on/fr-fr/products/widget, not the French home page. Most headless CMSes expose the translated slug for each document. Thread it through.Per-locale 404 pages. A user browsing the German site should get a German 404, not an English one. Put
not-found.tsxinside[locale].RTL support if you're shipping Arabic, Hebrew, or Farsi. That's a
dir="rtl"on<html>and some CSS logical properties (margin-inline-startinstead ofmargin-left). Not a library problem, a styling problem.
When you actually do need a library
We're not saying never use next-intl or react-intl. If your codebase has pluralisation rules that vary by locale (one apple, two apples, twelve apples), or rich ICU message formatting with gendered nouns and complex interpolation, those libraries earn their keep. Same goes if your project doesn't have a CMS and translations live in JSON files in the repo.
For the more typical case (a marketing site, product site, or e-commerce front end where a headless CMS holds the content), you can ship the whole thing with: a [locale] segment, a small JSON dictionary for UI strings, a properly-set lang attribute, locale-aware metadata, and a sitemap that actually declares its alternates. That's the full kit.
The instinct is to reach for a package because internationalization sounds hard. Most of the time, when a headless CMS is in the picture, you'd be paying to solve a problem you already paid the CMS to solve.
Got a multi-locale build coming up and not sure where to start? Get in touch and we'll walk you through it.