next-intl for UI strings (under messages/) and a thin
naming-convention layer for MDX content (under content/). Once you internalize
the conventions there is no extra ceremony — just write twice.
Prerequisites
None. The plumbing is wired up; you only add files.How locales are routed
Routing isas-needed (src/i18n/routing.ts):
- English (the default) lives at the root:
/docs/quickstart - Chinese is prefixed:
/zh/docs/quickstart - Locale list comes from
siteConfig.i18n.locales(['en', 'zh'])
setRequestLocale(locale) at the top —
without it, server components fall back to the default locale and your t(...)
calls go silent.
Naming convention for MDX
For each piece of content, ship two files side by side:.zh.mdx suffix is the only signal — there is no separate folder, no
JSON manifest. The helpers in src/lib/source-helpers.ts strip the suffix to
compute the canonical slug.
Locale-aware lookups
Three helpers cover everything:pickByLocale('zh') returns the .zh.mdx if it exists, otherwise the English
fallback so your /zh/ pages never 404 on missing translations.
UI strings — both files, same keys
Translations live inmessages/en.json and messages/zh.json. The two files
must have identical key shapes; the structural diff is enforced by
scripts/check-i18n.mjs.
en.json, add it to zh.json in the same place —
even if the Chinese is a placeholder. The validator will yell otherwise.
Adding a new locale
- Append to
siteConfig.i18n.locales(e.g.['en', 'zh', 'ja']). - Create
messages/ja.jsonmirroringen.json. - For any MDX you want translated, ship
slug.ja.mdx. - Add a new branch in
pickByLocaleif you want non-en fallbacks.
Verify it works
t('foo.bar')
call that doesn’t resolve under the namespace declared in the same file.
Common pitfalls
- Missing
.zh.mdx— the/zh/page silently falls back to English. Run the dev server and click around in Chinese mode to catch this. - Date drift between variants — when you update an English blog post, also
bump the date in the
.zh.mdx. Otherwise the index sort order diverges. - Template
t()calls —t(\Foo.$`)` cannot be statically verified. The validator only checks the prefix exists. Smoke-test the page. - Missing
setRequestLocale(locale)— locale-prefixed pages without this call render with the default locale’s messages. Easy to miss. - Using
next/linkdirectly — always importLinkanduseRouterfrom@/i18n/navigationso locale prefixes survive client navigation.
Official docs
- next-intl.dev — full reference for the i18n layer
- next-intl App Router — request config & routing
- Next.js i18n routing — Next.js fundamentals