next-intl
In short: A library for multilingual support (i18n) in Next.js projects — translates hard-coded UI texts via central translation files.
In more detail: Texts live in JSON files per language (e.g. messages/de.json, messages/en.json) and are retrieved in the code by key via useTranslations/getTranslations (t("key")) instead of being hard-coded. Important: next-intl only translates these UI texts, not database content (e.g. self-written blog articles) — that would need a feature of its own.
Our context: Introduced at Emzett for German/English, without a URL prefix (/de/, /en/) — the language lives in a cookie instead. Switched over gradually, page by page (footer, header, shop, tickets, …), not all at once.
In Depth
Structure of the translation files
Translation files are nested JSON objects, so that related texts stay grouped together:
{
"Cart": {
"title": "Cart",
"empty": "Your cart is empty.",
"itemCount": "{count, plural, one {# item} other {# items}}"
}
}This grouping (one namespace per UI area, e.g. Cart, Checkout, Footer) has a practical benefit beyond mere tidiness: useTranslations("Cart") returns a translation function restricted to this namespace, so shorter keys (t("title") instead of t("Cart.title")) are enough in the rest of the code, and name collisions between areas are ruled out.
ICU message syntax
itemCount shows an important next-intl capability: ICU message syntax for plural forms — instead of writing count === 1 ? "item" : "items" logic in the code yourself (which quickly goes wrong for languages with more complex plural rules than German/English), the library handles it automatically and correctly per language. Some languages (e.g. Polish, Russian) have more than two plural forms (one for “one”, one for “few”, one for “many”) — ICU syntax maps this via additional categories (few, many) without ever needing a language-specific case distinction in the application code. Conditional texts can also be expressed this way, e.g. grammatical gender via select syntax ({gender, select, male {he} female {she} other {it}}).
Server-side and client-side usage
In Server Components, getTranslations() (async) is used; in Client Components, useTranslations() (a hook, synchronous, since the translations have already been delivered by the server). Both access the same JSON files, but the mechanism behind them differs: on the server side the messages are read directly from the file system; on the client side they are made available via a NextIntlClientProvider (which passes the relevant translations on to the client as props) — only the messages actually needed by Client Components should be passed, to keep the JavaScript bundle sent to the browser small.
Cookie-based vs. URL-based routing
The alternative to the cookie-based approach (which Emzett uses) would be URL-based routing (/de/shop, /en/shop) — the advantage there: search engines can index each language version separately and shared URLs are unambiguously language-specific (important for international SEO via hreflang tags); the disadvantage: more routing complexity (every route effectively exists twice), and users following wrongly shared links may end up in the wrong language instead of automatically landing in their preferred one. The cookie approach is simpler to implement and keeps “clean” URLs without a language prefix, but has the drawback that one and the same URL can show different content depending on the cookie — which is suboptimal from a pure SEO point of view, but usually not a practical problem for a project aimed primarily at one language region.
See also: Next.js App Router, i18n