<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Babelize</title>
    <description>The latest articles on DEV Community by Babelize (babelize).</description>
    <link>https://dev.to/babelize</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F15185%2F42468511-a827-410a-bc1d-b430082cc7bd.png</url>
      <title>DEV Community: Babelize</title>
      <link>https://dev.to/babelize</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/babelize"/>
    <language>en</language>
    <item>
      <title>JSON Translation Files Explained: A Beginner’s Guide to Localization</title>
      <dc:creator>Mohit Sapat</dc:creator>
      <pubDate>Sun, 25 Oct 2026 07:57:44 +0000</pubDate>
      <link>https://dev.to/babelize/json-translation-files-explained-a-beginners-guide-to-localization-3jbi</link>
      <guid>https://dev.to/babelize/json-translation-files-explained-a-beginners-guide-to-localization-3jbi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A JSON translation file stores your app's user-facing text for one language as key-value pairs, such as en.json and fr.json. Your code calls t("auth.login") instead of hardcoding text, and the i18n library returns the value from the active locale's file. Every locale uses identical keys; only the translated values change.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;A JSON translation file is where your app's words live. Learn how JSON locale files are structured, how the &lt;code&gt;t()&lt;/code&gt; function finds the right string, how to name and organize files per language, how to translate a JSON file without breaking it, and the mistakes that cause missing text in production.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a JSON Translation File?
&lt;/h2&gt;

&lt;p&gt;A JSON translation file (also called a JSON locale file or JSON language file) stores the user-facing text of your application for one language. Instead of writing text directly inside your components, you give every string a &lt;strong&gt;key&lt;/strong&gt; and put the actual words in a file per language. At runtime, your i18n library loads the file for the active locale and looks up each key.&lt;/p&gt;

&lt;p&gt;Think of it like a dictionary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"welcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"login"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Log in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"logout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Log out"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Profile"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;welcome&lt;/code&gt;, &lt;code&gt;login&lt;/code&gt;, &lt;code&gt;logout&lt;/code&gt;, and &lt;code&gt;profile&lt;/code&gt; are &lt;strong&gt;translation keys&lt;/strong&gt;. The values are the text users see. A French file has the exact same keys with French values, and a German file has the same keys with German values. That is the whole contract of a multi-language JSON setup: &lt;strong&gt;keys are identical across every locale, only the values change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;JSON became the default format for web localization because every JavaScript runtime can parse it natively, bundlers can import it directly, and it maps cleanly to the nested objects that libraries like next-intl, react-i18next, vue-i18n, and FormatJS expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Use JSON Instead of Hardcoding Text?
&lt;/h2&gt;

&lt;p&gt;On a small project, writing text directly inside your components feels normal. The moment someone asks to support French, then German, then Japanese, every hardcoded string becomes a file you have to edit, a review you have to repeat, and a place a translation can be forgotten.&lt;/p&gt;

&lt;p&gt;Instead, your component asks for a key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;{t("welcome")}&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the component does not care which language the user speaks. The translation file does the rest. This separation is the core of internationalization; if the distinction between preparing code and adapting content is new to you, our explainer on &lt;a href="https://www.babelize.co/blogs/i18n-vs-l10n/" rel="noopener noreferrer"&gt;i18n vs l10n&lt;/a&gt; covers it in detail.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding a language means adding a file, not editing components&lt;/li&gt;
&lt;li&gt;Translators can work on text without touching the codebase&lt;/li&gt;
&lt;li&gt;Copy changes go through a single, reviewable place&lt;/li&gt;
&lt;li&gt;Tools can diff, validate, and machine-check the files in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Flat vs Nested Keys
&lt;/h2&gt;

&lt;p&gt;There are two common ways to shape a JSON translation file. A &lt;strong&gt;flat&lt;/strong&gt; file uses one level of keys, usually with dots in the key name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"auth.login.title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sign in to your account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"auth.login.button"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Log in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"auth.logout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Log out"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;strong&gt;nested&lt;/strong&gt; file groups related strings into objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"login"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sign in to your account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Log in"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"logout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Log out"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In most libraries you read both shapes the same way, with dot notation: &lt;code&gt;t("auth.login.button")&lt;/code&gt;. Nested files are easier to scan and let you pass a whole group to one component. next-intl, for example, expects nested messages and lets you scope a hook to a branch with &lt;code&gt;useTranslations("auth.login")&lt;/code&gt;, then call &lt;code&gt;t("button")&lt;/code&gt;. Flat files are easier to grep and diff line by line, and some translation tools prefer them. Pick one shape per project and stick with it; mixing them leads to keys that resolve in one locale and not another.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1srjy3nzzkqmk6hq2isz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1srjy3nzzkqmk6hq2isz.webp" alt="Side-by-side diagram of the same strings as flat keys (" width="800" height="512"&gt;&lt;/a&gt; login &amp;gt; title and button, auth &amp;gt; logout). Both resolve t("auth.login.button") to "Log in"; next-intl can scope a nested branch with useTranslations("auth.login") and then call t("button")."/&amp;gt;&lt;/p&gt;

&lt;p&gt;Flat and nested files hold the same strings and resolve the same dot-notation key.&lt;/p&gt;

&lt;p&gt;Whichever shape you choose, &lt;strong&gt;name keys by meaning and location, not by content.&lt;/strong&gt; &lt;code&gt;checkout.payButton&lt;/code&gt; survives a copy change from "Pay now" to "Complete purchase". A key called &lt;code&gt;payNow&lt;/code&gt; does not, and a key called &lt;code&gt;text1&lt;/code&gt; tells nobody anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming and Organizing Locale Files
&lt;/h2&gt;

&lt;p&gt;Name each file after a BCP 47 language tag: &lt;code&gt;en.json&lt;/code&gt;, &lt;code&gt;fr.json&lt;/code&gt;, &lt;code&gt;de.json&lt;/code&gt;, &lt;code&gt;ja.json&lt;/code&gt;, and add a region only when content actually differs, as in &lt;code&gt;pt-BR.json&lt;/code&gt; and &lt;code&gt;pt-PT.json&lt;/code&gt;. Use hyphens rather than underscores, since the JavaScript Intl API and HTML &lt;code&gt;lang&lt;/code&gt; attributes expect hyphenated tags. Our guide to &lt;a href="https://www.babelize.co/blogs/locale-codes-bcp-47-explained/" rel="noopener noreferrer"&gt;locale codes and BCP 47&lt;/a&gt; covers the traps, such as Japanese being &lt;code&gt;ja&lt;/code&gt;, not &lt;code&gt;jp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A single file per locale is fine for small apps. As the app grows, split each locale into a folder of &lt;strong&gt;namespaces&lt;/strong&gt;, one per feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locales/
├── en/
│   ├── common.json
│   ├── auth.json
│   └── billing.json
├── fr/
│   ├── common.json
│   ├── auth.json
│   └── billing.json
└── pt-BR/
    ├── common.json
    ├── auth.json
    └── billing.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Namespaces keep files small enough to review, reduce merge conflicts when several people add strings at once, and let a library load only the namespaces a page needs instead of every string in the app.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0dsw7ba4t2wyvei90gn.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0dsw7ba4t2wyvei90gn.webp" alt="Diagram of locale file layouts. Small app: a messages folder with one file per BCP 47 locale: en.json, fr.json, de.json, ja.json, pt-BR.json and pt-PT.json. Growing app: a locales folder with en, fr and pt-BR subfolders, each holding common.json, auth.json and billing.json namespaces, for smaller reviews, fewer merge conflicts and loading only the namespaces a page needs." width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start with one file per locale; split each locale into feature namespaces as the app grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  How t() Finds a Translation
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;t()&lt;/code&gt; function (short for "translate") does not translate anything. It is a lookup. Given a key, it resolves the value in the active locale's messages, fills in any variables, and returns a string. Here is a minimal version in plain JavaScript that shows the moving parts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your cart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;greeting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hi {name}!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Votre panier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lookup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{(\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;`{&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;}`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cart.title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                      &lt;span class="c1"&gt;// "Votre panier"&lt;/span&gt;
&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cart.greeting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Léa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// "Hi Léa!" (fell back to English)&lt;/span&gt;
&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cart.total&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                      &lt;span class="c1"&gt;// "cart.total" (missing everywhere)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real libraries add caching, plural rules, and formatting, but follow the same three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Resolve the key&lt;/strong&gt; by walking the dot path through the active locale's messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fall back&lt;/strong&gt; when it is missing. i18next walks its &lt;code&gt;fallbackLng&lt;/code&gt; chain (for example &lt;code&gt;pt-BR&lt;/code&gt; → &lt;code&gt;pt&lt;/code&gt; → &lt;code&gt;en&lt;/code&gt;). next-intl reports the missing message through its &lt;code&gt;onError&lt;/code&gt; handler and renders a fallback, by default the key path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interpolate&lt;/strong&gt; variables into the string.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That fallback behavior is why a missing key rarely crashes your app. Instead, a French user quietly sees English, or worse, sees &lt;code&gt;cart.total&lt;/code&gt; on screen. Treat missing keys as bugs and catch them before users do: our guide to &lt;a href="https://www.babelize.co/blogs/automate-app-localization-ai-react-github/" rel="noopener noreferrer"&gt;automating app localization&lt;/a&gt; includes a short Node.js script that fails CI when a locale is missing keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Placeholders: {name} vs {{name}}
&lt;/h2&gt;

&lt;p&gt;Variables are where JSON translation files stop being interchangeable between libraries. The two dominant syntaxes look almost identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ICU syntax: next-intl, react-intl / FormatJS, vue-i18n
"greeting": "Welcome back, {name}!"

// i18next default syntax: i18next, react-i18next
"greeting": "Welcome back, {{name}}!"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are called the same way, &lt;code&gt;t("greeting", { name: user.firstName })&lt;/code&gt;, but a file written for one library will print literal braces in the other. If you migrate between libraries, convert placeholders as part of the move.&lt;/p&gt;

&lt;p&gt;Always use placeholders rather than concatenating fragments. &lt;code&gt;t("hello") + " " + name&lt;/code&gt; forces English word order on every language. A single message with &lt;code&gt;{name}&lt;/code&gt; lets the translator put the name wherever the grammar needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plurals in JSON Translation Files
&lt;/h2&gt;

&lt;p&gt;Plurals are the other big syntax split. ICU-based libraries keep every plural form inside one message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"itemCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{count, plural, =0 {Your cart is empty} one {# item} other {# items}}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;i18next (v21 and later) uses separate keys with a plural-category suffix, and picks one based on the &lt;code&gt;count&lt;/code&gt; you pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"itemCount_zero"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your cart is empty"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"itemCount_one"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{count}} item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"itemCount_other"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{count}} items"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way, the set of plural forms belongs to each language, not to English. Polish needs &lt;code&gt;one&lt;/code&gt;, &lt;code&gt;few&lt;/code&gt;, &lt;code&gt;many&lt;/code&gt;, and &lt;code&gt;other&lt;/code&gt;; Japanese needs only &lt;code&gt;other&lt;/code&gt;. A Polish i18next file therefore has keys like &lt;code&gt;itemCount_few&lt;/code&gt; that the English file does not. Our &lt;a href="https://www.babelize.co/blogs/icu-messageformat-plurals-guide/" rel="noopener noreferrer"&gt;ICU MessageFormat plurals guide&lt;/a&gt; explains the categories and why a &lt;code&gt;count === 1&lt;/code&gt; check breaks in most languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading JSON Files in Real Libraries
&lt;/h2&gt;

&lt;h3&gt;
  
  
  next-intl (Next.js App Router)
&lt;/h3&gt;

&lt;p&gt;next-intl loads one messages file per locale on the server, in the request config. Only the locale being rendered is imported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/i18n/request.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getRequestConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hasLocale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./routing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;getRequestConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;requestLocale&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;requestLocale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hasLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultLocale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`../../messages/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server Components then call &lt;code&gt;getTranslations()&lt;/code&gt; and Client Components call &lt;code&gt;useTranslations()&lt;/code&gt;. The full setup is in our &lt;a href="https://www.babelize.co/blogs/implement-i18n-nextjs-next-intl/" rel="noopener noreferrer"&gt;step-by-step next-intl tutorial&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  react-i18next
&lt;/h3&gt;

&lt;p&gt;react-i18next takes resources organized by language and namespace. You can bundle them directly, or lazy-load them over HTTP with a backend plugin such as &lt;code&gt;i18next-http-backend&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;i18n&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;i18next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;initReactI18next&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-i18next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;enCommon&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./locales/en/common.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;frCommon&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./locales/fr/common.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initReactI18next&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;common&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;enCommon&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;common&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frCommon&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fallbackLng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;defaultNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;interpolation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;escapeValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// React already escapes output&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Components use &lt;code&gt;const { t } = useTranslation("common")&lt;/code&gt;. If you are still choosing between the two libraries, see our &lt;a href="https://www.babelize.co/blogs/next-intl-vs-react-i18next/" rel="noopener noreferrer"&gt;next-intl vs react-i18next comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  No framework (plain HTML)
&lt;/h3&gt;

&lt;p&gt;A static site can use the same files with a few lines of JavaScript: mark elements with &lt;code&gt;data-i18n&lt;/code&gt;, fetch &lt;code&gt;locales/fr.json&lt;/code&gt;, and swap the text. &lt;a href="https://www.babelize.co/blogs/translate-static-html-website/" rel="noopener noreferrer"&gt;How to translate a static HTML website&lt;/a&gt; walks through a tested runtime and a build step that writes one page per language.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Translate a JSON File Safely
&lt;/h2&gt;

&lt;p&gt;Translating a JSON file looks like a simple find-and-replace job. It is the step where most localization bugs are introduced, because a translator or a machine translation tool that does not understand the file's rules can break it in ways that only show up at runtime.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Translate values, never keys.&lt;/strong&gt; &lt;code&gt;"login": "Connexion"&lt;/code&gt; is right. &lt;code&gt;"connexion": "Connexion"&lt;/code&gt; means the French build can no longer find &lt;code&gt;login&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preserve placeholders exactly.&lt;/strong&gt; &lt;code&gt;{name}&lt;/code&gt; and &lt;code&gt;{{count}}&lt;/code&gt; must survive untouched. A translated &lt;code&gt;{nom}&lt;/code&gt; renders as a literal or throws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep ICU syntax intact.&lt;/strong&gt; Keywords like &lt;code&gt;plural&lt;/code&gt;, &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;one&lt;/code&gt;, and &lt;code&gt;other&lt;/code&gt; are code, not English. Only the text inside the branches gets translated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep rich-text tags.&lt;/strong&gt; If a message contains &lt;code&gt;&amp;lt;link&amp;gt;Terms&amp;lt;/link&amp;gt;&lt;/code&gt;, the tag names must stay the same so your component can map them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape quotes and save as UTF-8.&lt;/strong&gt; A French value containing a straight double quote must be written as &lt;code&gt;\"&lt;/code&gt;. Accented and non-Latin characters can be stored directly in UTF-8; there is no need for &lt;code&gt;\u&lt;/code&gt; escapes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the result.&lt;/strong&gt; Parse every file after translation. One missing comma makes the entire locale fail to load.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generic machine translation, including pasting a whole file into a chatbot, tends to fail on exactly these points: it translates keys, rewrites placeholders, "fixes" ICU keywords, or returns a file with a trailing comma. It also lacks context; "Open" as a button and "Open" as a ticket status translate differently in many languages. Whatever tool you use, run the same automated checks on its output that you would run on a human translator's. For step-by-step scripts (a translation API with placeholder protection, an LLM, validation and plural forms), see &lt;a href="https://www.babelize.co/blogs/translate-json-files-i18n/" rel="noopener noreferrer"&gt;how to translate JSON files for i18n&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON vs Other Translation File Formats
&lt;/h2&gt;

&lt;p&gt;JSON dominates web projects, but it is not the only format. You will meet these as soon as you add a mobile app or work with a translation agency:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;JavaScript web apps (next-intl, i18next, vue-i18n)&lt;/td&gt;
&lt;td&gt;Native to JS, supports nesting, no comments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YAML&lt;/td&gt;
&lt;td&gt;Ruby on Rails, some static site generators&lt;/td&gt;
&lt;td&gt;Allows comments, indentation-sensitive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PO / gettext&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.babelize.co/blogs/django-i18n-gettext-guide/" rel="noopener noreferrer"&gt;Django&lt;/a&gt;, PHP, Linux and C projects&lt;/td&gt;
&lt;td&gt;Source text as the key, translator comments, built-in plural forms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XLIFF&lt;/td&gt;
&lt;td&gt;Exchange with translation agencies and CAT tools&lt;/td&gt;
&lt;td&gt;XML standard holding source and target side by side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ARB&lt;/td&gt;
&lt;td&gt;Flutter&lt;/td&gt;
&lt;td&gt;JSON-based, ICU messages, metadata in &lt;code&gt;@key&lt;/code&gt; entries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.strings&lt;/code&gt; / &lt;code&gt;.xcstrings&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;iOS and macOS&lt;/td&gt;
&lt;td&gt;Apple's string catalogs; &lt;code&gt;.xcstrings&lt;/code&gt; is JSON-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strings.xml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;XML resources with a dedicated &lt;code&gt;plurals&lt;/code&gt; element&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One limitation stands out: JSON has no comments, so there is nowhere to tell a translator that "Save" is a verb on a button and not a noun. Teams work around this with a separate description file, a context field in their translation platform, or descriptive key names. If you are standardizing on JSON, see &lt;a href="https://docs.babelize.co/features/file-formats" rel="noopener noreferrer"&gt;how Babelize handles JSON locale files&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes With JSON Locale Files
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concatenating translated fragments.&lt;/strong&gt; Build whole sentences with placeholders, never by joining keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusing one key in two contexts.&lt;/strong&gt; Key by meaning, so each context can be translated independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key drift between locales.&lt;/strong&gt; A key added to &lt;code&gt;en.json&lt;/code&gt; but not to &lt;code&gt;de.json&lt;/code&gt; silently falls back to English. A key renamed in one file but not the others is the same bug in reverse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalid JSON.&lt;/strong&gt; Trailing commas, single quotes, and comments are all illegal in JSON and stop the locale from loading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate keys.&lt;/strong&gt; &lt;code&gt;JSON.parse&lt;/code&gt; does not complain about them; the last value wins, so an earlier translation silently disappears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encoding problems.&lt;/strong&gt; Save files as UTF-8 without a byte order mark. A BOM makes &lt;code&gt;JSON.parse&lt;/code&gt; throw, and non-UTF-8 encodings turn accents into garbage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One giant file.&lt;/strong&gt; Thousands of keys in a single file make reviews painful and force every page to load every string.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Catch Missing Keys in CI
&lt;/h2&gt;

&lt;p&gt;Key drift is easy to detect automatically. This Node script flattens every locale file and compares its keys against &lt;code&gt;en.json&lt;/code&gt;, failing the build if any are missing or extra:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/check-locales.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;flatten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keysOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keysOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keysOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: missing [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;] extra [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;]`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;JSON.parse&lt;/code&gt; runs on every file, the script also fails on invalid JSON. If you use i18next plural suffixes, strip &lt;code&gt;_zero&lt;/code&gt;, &lt;code&gt;_one&lt;/code&gt;, &lt;code&gt;_two&lt;/code&gt;, &lt;code&gt;_few&lt;/code&gt;, &lt;code&gt;_many&lt;/code&gt;, and &lt;code&gt;_other&lt;/code&gt; before comparing, since each language legitimately has a different set. To catch strings that never made it into a JSON file at all, pair this with pseudo-localization testing.&lt;/p&gt;

&lt;p&gt;If you would rather not maintain the script, &lt;a href="https://docs.babelize.co/cli/check" rel="noopener noreferrer"&gt;babelize check&lt;/a&gt; runs the same key comparison plus placeholder and plural checks with &lt;code&gt;npx @babelize/cli check&lt;/code&gt;. It only reads your files, so it needs no account and no network access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type-Safe Translation Keys
&lt;/h2&gt;

&lt;p&gt;With TypeScript, you can make a typo in a key a compile error instead of a blank label. Both major libraries type keys from your source-language JSON file through module augmentation. In next-intl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// global.d.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./messages/en.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;declare&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AppConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in react-i18next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// i18next.d.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;i18next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;common&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./locales/en/common.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;declare&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;i18next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CustomTypeOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;defaultNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;common&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;common&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With either in place, &lt;code&gt;t("auth.logn")&lt;/code&gt; fails type checking and your editor autocompletes valid keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a JSON translation file?
&lt;/h3&gt;

&lt;p&gt;A JSON translation file stores an application's user-facing text for one language as key-value pairs, for example en.json for English and fr.json for French. The code references keys, and the i18n library loads the file for the active locale and returns the matching value.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should I name JSON locale files?
&lt;/h3&gt;

&lt;p&gt;Name each file after a BCP 47 language tag, such as en.json, fr.json, or pt-BR.json, using hyphens rather than underscores. Add a region only when content differs between regions. For larger apps, use one folder per locale with a JSON file per feature namespace.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should translation keys be flat or nested?
&lt;/h3&gt;

&lt;p&gt;Both work, and most libraries read either shape with dot notation such as auth.login.button. Nested files are easier to scan and let you scope translations to a component; flat files are easier to search and diff. Choose one shape per project and use it consistently.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I translate a JSON file without breaking it?
&lt;/h3&gt;

&lt;p&gt;Translate only the values, never the keys. Keep placeholders such as {name} or {{count}}, ICU keywords like plural and other, and rich-text tags exactly as they are. Save the file as UTF-8, escape double quotes, and parse every file in CI to catch invalid JSON.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when a translation key is missing?
&lt;/h3&gt;

&lt;p&gt;Most libraries fall back instead of crashing. i18next tries the fallback language chain and then shows the key itself. next-intl reports the error and renders a fallback, by default the key path. Users see English or a raw key, so check for missing keys in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;JSON translation files look simple, and that is their strength: one file per locale, identical keys, values that change. The work is in keeping them that way. Use meaningful nested keys, BCP 47 file names, placeholders instead of concatenation, and real plural syntax. Translate values only, validate every file, and fail the build when locales drift apart.&lt;/p&gt;

&lt;p&gt;Keeping ten locale files in sync by hand is the part that does not scale. &lt;a href="https://www.babelize.co/" rel="noopener noreferrer"&gt;Babelize&lt;/a&gt; (our product) detects new and changed keys in your source JSON, generates placeholder-safe translations for every locale, and commits them back to your repository. You can run it from the terminal with the &lt;a href="https://docs.babelize.co/cli" rel="noopener noreferrer"&gt;Babelize CLI&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed by the Babelize team.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.babelize.co/blogs/json-translation-files-explained/" rel="noopener noreferrer"&gt;Babelize blog&lt;/a&gt;. Disclosure: Babelize is our product, an AI localization tool for Next.js, React and Vue apps: it turns hardcoded strings into a reviewed i18n pull request.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>json</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How We Turn a Next.js App's Hardcoded Strings Into a Reviewed i18n Pull Request</title>
      <dc:creator>Mohit Sapat</dc:creator>
      <pubDate>Sat, 24 Oct 2026 15:12:43 +0000</pubDate>
      <link>https://dev.to/babelize/how-we-turn-a-nextjs-apps-hardcoded-strings-into-a-reviewed-i18n-pull-request-4cae</link>
      <guid>https://dev.to/babelize/how-we-turn-a-nextjs-apps-hardcoded-strings-into-a-reviewed-i18n-pull-request-4cae</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Babelize parses every file with a Babel AST, extracts only user-facing text (whole JSX sentences become ICU messages with placeholders, tags and plurals), names keys from the file path and first words, hashes source text into a lockfile so reruns only translate what changed, validates every LLM translation for lost placeholders, broken ICU and untranslated output, then rewrites components to use next-intl and opens a diff or pull request for review.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Every React app starts with hardcoded text, and nobody wants to spend a sprint moving 900 strings into JSON files by hand. This is how Babelize does it: an AST pass that finds the text worth translating, keys that stay stable between runs, a lockfile so reruns cost nothing, an LLM whose output we refuse to trust, and a code rewrite that keeps your formatting. Plus the cases where it deliberately stops and asks you.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Text Lives Inside Components
&lt;/h2&gt;

&lt;p&gt;A typical Next.js page looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/dashboard/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Dashboard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Welcome back, &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Read the &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/terms"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;terms&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; before you continue.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Search projects"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"input"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; projects&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find-and-replace can't localize this. "Welcome back," and &lt;code&gt;{user.name}&lt;/code&gt; are one sentence, and other languages put the name somewhere else. The link sits in the middle of a sentence. &lt;code&gt;placeholder&lt;/code&gt; is user-facing, &lt;code&gt;className&lt;/code&gt; isn't. "projects" needs a plural form, and Polish has four of them. Doing it properly means understanding the code, not the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Find the Strings with an AST, Not a Regex
&lt;/h2&gt;

&lt;p&gt;Every file is parsed with &lt;code&gt;@babel/parser&lt;/code&gt; (JSX and TypeScript plugins, with error recovery so one broken file doesn't stop the run). Then visitors walk the tree looking for JSX text, whole JSX elements, string literals, template literals, &lt;code&gt;+&lt;/code&gt; concatenations and both branches of ternaries.&lt;/p&gt;

&lt;p&gt;Most of the work is deciding what &lt;em&gt;not&lt;/em&gt; to extract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Attributes:&lt;/strong&gt; only an allowlist is user-facing: &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;placeholder&lt;/code&gt;, &lt;code&gt;alt&lt;/code&gt;, &lt;code&gt;aria-label&lt;/code&gt;, &lt;code&gt;aria-description&lt;/code&gt;, &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt; (plus &lt;code&gt;accessibilityLabel&lt;/code&gt; and &lt;code&gt;accessibilityHint&lt;/code&gt; for React Native). &lt;code&gt;className&lt;/code&gt;, &lt;code&gt;href&lt;/code&gt; and everything else is left alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Objects:&lt;/strong&gt; a string under a copy-like key (&lt;code&gt;label&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;message&lt;/code&gt;) is UI text; under a data-like key (&lt;code&gt;href&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;className&lt;/code&gt;) it's data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calls:&lt;/strong&gt; &lt;code&gt;console.*&lt;/code&gt;, &lt;code&gt;throw&lt;/code&gt;, &lt;code&gt;new Error(...)&lt;/code&gt; and hook arguments are skipped, along with almost every other call argument.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Values that look like data:&lt;/strong&gt; URLs and paths, CSS selectors, colors, ISO dates, all-caps identifiers like &lt;code&gt;SKU12345&lt;/code&gt;, pure numbers and symbols, and text under three characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files:&lt;/strong&gt; &lt;code&gt;node_modules&lt;/code&gt;, build output, tests, specs and &lt;code&gt;.d.ts&lt;/code&gt; files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Sentences, not fragments
&lt;/h3&gt;

&lt;p&gt;The key decision: a JSX element with mixed children becomes &lt;strong&gt;one ICU message&lt;/strong&gt;, so translators see the whole sentence. Interpolations become named placeholders, and inline tags like &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;b&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt; become tags inside the message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Welcome back, {name}"
"Read the &amp;lt;link&amp;gt;terms&amp;lt;/link&amp;gt; before you continue."
"{count, plural, one {# project} other {# projects}}"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plural comes from a deliberately narrow rule: exactly one placeholder with a count-like name (&lt;code&gt;count&lt;/code&gt;, &lt;code&gt;total&lt;/code&gt;, &lt;code&gt;n&lt;/code&gt;, or ending in &lt;code&gt;Count&lt;/code&gt;), referenced directly, and followed by an English noun we can singularize. &lt;code&gt;{data?.count} items&lt;/code&gt; doesn't qualify, because if &lt;code&gt;data&lt;/code&gt; is undefined you'd ship "NaN items" in nine languages.&lt;/p&gt;

&lt;p&gt;When an element can't be folded into a sentence safely (a child expression with side effects, spread props, children passed through, literal braces in the text), it's kept as separate fragments and you get a warning instead of a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Keys That Stay Put
&lt;/h2&gt;

&lt;p&gt;Key names are boring on purpose. The namespace comes from the file path, with noise like &lt;code&gt;src&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;components&lt;/code&gt; and a trailing &lt;code&gt;page&lt;/code&gt; or &lt;code&gt;index&lt;/code&gt; stripped. The key is the first five words, camelCased. So "Welcome back, {name}" in &lt;code&gt;src/app/dashboard/page.tsx&lt;/code&gt; becomes &lt;code&gt;dashboard.welcomeBack&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Collisions get &lt;code&gt;_2&lt;/code&gt;, &lt;code&gt;_3&lt;/code&gt; suffixes. On later runs, a string whose text hasn't changed gets its existing key back instead of a new copy, so a rerun never renames keys under you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: A Lockfile, So Reruns Are Nearly Free
&lt;/h2&gt;

&lt;p&gt;Localization isn't a one-off, because you keep shipping English. So every key's source text is hashed (SHA-256) into a lockfile, along with its translations per locale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;.babelize/local/babelize-lock.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the next run, keys fall into four groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unchanged:&lt;/strong&gt; reused, nothing sent anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New:&lt;/strong&gt; translated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changed source:&lt;/strong&gt; the hash differs, so it's translated again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing a locale:&lt;/strong&gt; you added Japanese, or the glossary changed, so only the gaps are translated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Commit the lockfile with your code. Teammates and CI then get the same "already translated" view, and a rerun after a small feature only pays for the handful of strings you added.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Translate, Then Don't Trust the Output
&lt;/h2&gt;

&lt;p&gt;Keys go to the model in batches of 15, three batches at a time. You can use your own key (Anthropic, OpenAI, Gemini, Groq, OpenRouter) or Babelize's hosted translation. Before sending, placeholders, tags and brand names are swapped for opaque tokens like &lt;code&gt;[#VAR_0#]&lt;/code&gt;, and the prompt says each must come back exactly once. ICU messages keep their argument names, &lt;code&gt;#&lt;/code&gt;, and the target language's own plural categories.&lt;/p&gt;

&lt;p&gt;Then every value is checked before it's written:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Placeholder lost:&lt;/strong&gt; a token is missing or duplicated, so &lt;code&gt;{name}&lt;/code&gt; would never render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalid ICU:&lt;/strong&gt; the plural or select no longer parses, or a branch was dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markup mismatch:&lt;/strong&gt; the &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag disappeared from a branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untranslated:&lt;/strong&gt; the model handed the English back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rejected key gets one targeted repair attempt, with a note telling the model what was wrong. If that fails too, the key is &lt;strong&gt;left out of the locale file&lt;/strong&gt;. It's never filled with English and never shipped broken. It's also kept out of the lockfile, so the next run retries it, and the CLI exits with code 6 so CI notices a partial run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Rewrite the Code Without Wrecking Your Diff
&lt;/h2&gt;

&lt;p&gt;Injection reuses the exact source positions recorded during extraction, and rewrites files with recast, so your formatting, comments and quote style survive. For Next.js App Router projects with next-intl:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A component that's already &lt;code&gt;async&lt;/code&gt; and isn't in a &lt;code&gt;'use client'&lt;/code&gt; file gets &lt;code&gt;await getTranslations()&lt;/code&gt;. Everything else gets &lt;code&gt;useTranslations()&lt;/code&gt;. Components are never made async behind your back.&lt;/li&gt;
&lt;li&gt;Rich text becomes &lt;code&gt;t.rich()&lt;/code&gt; with your original element passed back in, so the link keeps its &lt;code&gt;href&lt;/code&gt; and props.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTranslations&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dashboard.welcomeBack&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rich&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dashboard.readTheTermsBeforeYou&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/terms"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the project is wired up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;i18n/request.ts&lt;/code&gt; picks the language from a &lt;code&gt;NEXT_LOCALE&lt;/code&gt; cookie, then the browser's &lt;code&gt;Accept-Language&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next.config&lt;/code&gt; is wrapped with next-intl's plugin, and the root layout's children with &lt;code&gt;NextIntlClientProvider&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Messages go to &lt;code&gt;public/locales/&amp;lt;locale&amp;gt;.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No middleware and no &lt;code&gt;/de/...&lt;/code&gt; routes, so your URLs, auth and redirects stay untouched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On local runs, your own Prettier or Biome config then runs on the changed source files, so the diff reads like a teammate wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Refuses to Guess
&lt;/h2&gt;

&lt;p&gt;The interesting part of a tool like this is where it stops. Babelize reports these instead of improvising:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Module-level constants&lt;/strong&gt; used outside a render: translating them at import time would freeze one language. You get "move it inside the component".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strings with no place for a hook&lt;/strong&gt;, outside any component: extracted and translated, but listed as not wired in, with the reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic keys&lt;/strong&gt; like &lt;code&gt;t(`status.${s}`)&lt;/code&gt;: once one appears, the "unused keys" report turns itself off rather than flagging keys that are in use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A run that would delete existing translations&lt;/strong&gt; aborts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoded dates and currency&lt;/strong&gt; get a warning, because formatting them belongs to &lt;code&gt;Intl&lt;/code&gt;, not to translators.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Local Diff or Pull Request
&lt;/h2&gt;

&lt;p&gt;There are two ways to run the same pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx babelize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs in your project folder. It needs a git repo and a clean working tree, works on a temporary copy of the project, and only writes back if none of your files changed in the meantime. You review with &lt;code&gt;git diff&lt;/code&gt; and commit, lockfile included. With your own LLM key in the environment you don't need an account.&lt;/p&gt;

&lt;p&gt;Or connect the repo with the GitHub App. The same pipeline then opens a pull request, and can keep one PR per branch up to date as you push.&lt;/p&gt;

&lt;p&gt;Either way the rule is the same: nothing lands in your code until a human reviews it. Localization tools that write straight to production are how you end up with "Willkommen zurück, undefined".&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed by the Babelize team. Every technical detail was checked against the Babelize engine source.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.babelize.co/blogs/how-babelize-localizes-nextjs-app/" rel="noopener noreferrer"&gt;Babelize blog&lt;/a&gt;. Disclosure: Babelize is our product, an AI localization tool for Next.js, React and Vue apps: it turns hardcoded strings into a reviewed i18n pull request.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>i18n</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Plurals, Genders, and Variables: ICU MessageFormat for Developers</title>
      <dc:creator>Mohit Sapat</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:07:17 +0000</pubDate>
      <link>https://dev.to/babelize/plurals-genders-and-variables-icu-messageformat-for-developers-5977</link>
      <guid>https://dev.to/babelize/plurals-genders-and-variables-icu-messageformat-for-developers-5977</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; ICU MessageFormat is a translation string syntax that puts plural rules, gender, and variables inside one message, such as {count, plural, one {# item} other {# items}}. Your code passes raw values; each locale's translation picks its own CLDR plural categories (zero, one, two, few, many, other), so Polish gets four forms, Arabic six, and Japanese one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your English pluralization logic will break in Polish, Arabic, and Russian. Learn how ICU MessageFormat handles plurals, gender, numbers, and nested variables so your translated strings stay grammatical in every language you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  ICU MessageFormat Syntax Cheat Sheet
&lt;/h2&gt;

&lt;p&gt;Every ICU construct you will actually use, with what it renders. The &lt;code&gt;#&lt;/code&gt; inside a plural branch is replaced by the formatted number.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Construct&lt;/th&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Renders as&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Variable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Hello, {name}!&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hello, Ana!&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plural&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{count, plural, one {# file} other {# files}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 file · 5 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exact match&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{count, plural, =0 {No files} one {# file} other {# files}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Select&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{gender, select, female {She} male {He} other {They}} replied&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;They replied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordinal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;21st · 22nd · 23rd · 4th&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offset&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{n, plural, offset:1 =0 {Nobody} =1 {{name}} one {{name} and # other} other {{name} and # others}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ana and 2 others (n = 3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Number&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{price, number, ::currency/EUR}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;€1,234.50 (en) · 1.234,50 € (de)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{when, date, medium}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Oct 13, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich text tag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;You have &amp;lt;b&amp;gt;{count}&amp;lt;/b&amp;gt; new messages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You have &lt;strong&gt;3&lt;/strong&gt; new messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escaping&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'{literal}'&lt;/code&gt; and &lt;code&gt;''&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;{literal} and '&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rules hold in every message: a &lt;code&gt;plural&lt;/code&gt;, &lt;code&gt;select&lt;/code&gt;, or &lt;code&gt;selectordinal&lt;/code&gt; must end with an &lt;code&gt;other&lt;/code&gt; branch, and the translator writes the whole sentence inside each branch. The sections below explain each construct and the mistakes that break them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug That Ships in Almost Every App
&lt;/h2&gt;

&lt;p&gt;Here is a line of code that exists in thousands of production codebases right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1 item&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; items`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works perfectly. In English. The moment you translate it, it quietly becomes wrong in most of the world's languages, and nobody on your team will notice because nobody on your team reads Polish.&lt;/p&gt;

&lt;p&gt;English has two plural forms: singular and everything else. That is unusually simple. According to the &lt;a href="https://cldr.unicode.org/index/cldr-spec/plural-rules" rel="noopener noreferrer"&gt;Unicode CLDR plural rules&lt;/a&gt;, languages fall into six possible plural categories — &lt;code&gt;zero&lt;/code&gt;, &lt;code&gt;one&lt;/code&gt;, &lt;code&gt;two&lt;/code&gt;, &lt;code&gt;few&lt;/code&gt;, &lt;code&gt;many&lt;/code&gt;, and &lt;code&gt;other&lt;/code&gt; — and each language uses a different subset.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Plural categories&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Japanese, Korean, Chinese, Vietnamese&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;other only: same form for 0, 1, 2, 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;English, German, Dutch, Turkish&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;one / other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spanish, Italian&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;one / many / other: many covers exact millions (1 millón de usuarios)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;French, Portuguese (BR)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;one / many / other: 0 and 1 share one; many covers exact millions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Czech, Slovak&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;one / few / many / other: many is for decimals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polish, Russian, Ukrainian&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;one / few / many / other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arabic&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;zero / one / two / few / many / other&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Russian is a good illustration of why a ternary cannot save you. The word for "file" changes based on the &lt;em&gt;last digit&lt;/em&gt; of the number: 1 файл, 2 файла, 5 файлов, 21 файл, 22 файла, 25 файлов. There is no way to express that with &lt;code&gt;count === 1&lt;/code&gt;. There is also no way to express it by adding a second key called &lt;code&gt;items_plural&lt;/code&gt;, which is the workaround most teams reach for next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ICU MessageFormat Actually Is
&lt;/h2&gt;

&lt;p&gt;ICU MessageFormat is a string syntax, originally from the International Components for Unicode project, that moves grammatical decisions out of your code and into the translation string itself. It is supported by &lt;code&gt;next-intl&lt;/code&gt;, &lt;code&gt;react-intl&lt;/code&gt; / FormatJS, &lt;code&gt;i18next&lt;/code&gt; (via the ICU plugin), Android, iOS, Java, and most &lt;a href="https://www.babelize.co/blogs/lokalise-crowdin-alternatives-for-developers/" rel="noopener noreferrer"&gt;translation management platforms&lt;/a&gt;. If you are still choosing a React library, our &lt;a href="https://www.babelize.co/blogs/next-intl-vs-react-i18next/" rel="noopener noreferrer"&gt;next-intl vs react-i18next comparison&lt;/a&gt; covers how each one handles ICU.&lt;/p&gt;

&lt;p&gt;The basic ICU syntax is a placeholder in curly braces: &lt;code&gt;{argument}&lt;/code&gt; for a plain value, or &lt;code&gt;{argument, type, options}&lt;/code&gt; where the type is &lt;code&gt;plural&lt;/code&gt;, &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;selectordinal&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, or &lt;code&gt;time&lt;/code&gt;. The same message in ICU, stored in an ordinary &lt;a href="https://www.babelize.co/blogs/json-translation-files-explained/" rel="noopener noreferrer"&gt;JSON translation file&lt;/a&gt;, looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fileCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{count, plural, one {# file} other {# files}}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Polish translator, without touching your code, writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fileCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your component stays identical in both cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fileCount&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole idea. &lt;strong&gt;The code passes data. The translation decides grammar.&lt;/strong&gt; The &lt;code&gt;#&lt;/code&gt; is a placeholder for the number, already formatted for the locale — so a German user sees &lt;code&gt;1.234 Dateien&lt;/code&gt; and an American sees &lt;code&gt;1,234 files&lt;/code&gt; without you writing a single formatting branch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feu3fs7rzktipwhmcq9bf.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feu3fs7rzktipwhmcq9bf.webp" alt="Diagram of ICU plural resolution: t(" width="800" height="544"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The count picks a CLDR plural category per locale, and the category picks the branch. The same code works for English, Polish and Arabic.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Four Constructs Worth Knowing
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Simple arguments
&lt;/h3&gt;

&lt;p&gt;The basic case — a variable dropped into a sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"greeting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome back, {name}!"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This already buys you something important: the translator controls where &lt;code&gt;{name}&lt;/code&gt; goes. In Japanese, the name comes before the greeting. If you had written &lt;code&gt;t("welcome") + " " + name&lt;/code&gt;, the translator would have been stuck.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. plural
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"notifications"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{count, plural,
  =0 {No new notifications}
  one {# new notification}
  other {# new notifications}
}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the difference between &lt;code&gt;=0&lt;/code&gt; and &lt;code&gt;zero&lt;/code&gt;. The &lt;code&gt;=0&lt;/code&gt; form is an &lt;em&gt;exact match&lt;/em&gt; — it fires whenever the value is literally 0, in any language. The &lt;code&gt;zero&lt;/code&gt; category is a &lt;em&gt;grammatical&lt;/em&gt; category used by languages like Arabic and Latvian, and it may not fire for the number 0 at all in a language that does not have it. Use &lt;code&gt;=0&lt;/code&gt; for "empty state" copy and leave the named categories for grammar.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. select
&lt;/h3&gt;

&lt;p&gt;For gender agreement and any other branching that is not numeric:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"invited"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{gender, select,
  male {He invited you to {project}}
  female {She invited you to {project}}
  other {They invited you to {project}}
}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters more than English developers expect. In Spanish, French, Russian, Hebrew, and Arabic, adjectives and past participles agree with gender. A message like "You are subscribed" has two different forms in Russian depending on who is reading it. If your product knows the user's gender, expose it as a &lt;code&gt;select&lt;/code&gt; argument. If it does not, that is a real product decision — tell your translators so they can pick a neutral phrasing instead of guessing.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. selectordinal
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"You finished {place, selectordinal,
  one {#st} two {#nd} few {#rd} other {#th}
} in the leaderboard"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ordinals follow their own rules that are unrelated to plural rules — 1st, 2nd, 3rd, 4th, 21st, 22nd. Hardcoding a suffix table is a classic source of "22th" bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  ICU Plural Rules by Language
&lt;/h2&gt;

&lt;p&gt;These are the CLDR categories each language uses, with example numbers that land in each one. Cardinal rules drive &lt;code&gt;plural&lt;/code&gt;; ordinal rules drive &lt;code&gt;selectordinal&lt;/code&gt;. Your translation for a locale needs a branch for every category that locale uses, and nothing else.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Cardinal (plural)&lt;/th&gt;
&lt;th&gt;Ordinal (selectordinal)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;English&lt;/td&gt;
&lt;td&gt;one (1) · other (0, 2, 3)&lt;/td&gt;
&lt;td&gt;one (1st, 21st) · two (2nd, 22nd) · few (3rd, 23rd) · other (4th)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;German, Dutch&lt;/td&gt;
&lt;td&gt;one (1) · other (0, 2, 3)&lt;/td&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spanish, Italian&lt;/td&gt;
&lt;td&gt;one (1) · many (1,000,000) · other (0, 2, 3)&lt;/td&gt;
&lt;td&gt;Spanish: other · Italian: many (8, 11, 80) · other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;French&lt;/td&gt;
&lt;td&gt;one (0, 1, 1.5) · many (1,000,000) · other (2, 3)&lt;/td&gt;
&lt;td&gt;one (1er) · other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Portuguese&lt;/td&gt;
&lt;td&gt;one (0, 1, 1.5) · many (1,000,000) · other (2, 3)&lt;/td&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Russian, Ukrainian&lt;/td&gt;
&lt;td&gt;one (1, 21, 31) · few (2–4, 22) · many (0, 5–20) · other (1.5)&lt;/td&gt;
&lt;td&gt;Russian: other · Ukrainian: few (3, 23) · other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polish&lt;/td&gt;
&lt;td&gt;one (1) · few (2–4, 22) · many (0, 5–21) · other (1.5)&lt;/td&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Czech, Slovak&lt;/td&gt;
&lt;td&gt;one (1) · few (2–4) · many (1.5) · other (0, 5, 6)&lt;/td&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arabic&lt;/td&gt;
&lt;td&gt;zero (0) · one (1) · two (2) · few (3–10) · many (11–99) · other (100, 101)&lt;/td&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hebrew&lt;/td&gt;
&lt;td&gt;one (1) · two (2) · other (0, 3, 4)&lt;/td&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hindi&lt;/td&gt;
&lt;td&gt;one (0, 1) · other (2, 3)&lt;/td&gt;
&lt;td&gt;one (1) · two (2, 3) · few (4) · many (6) · other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latvian&lt;/td&gt;
&lt;td&gt;zero (0, 10–20) · one (1, 21, 31) · other (2–9)&lt;/td&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Welsh&lt;/td&gt;
&lt;td&gt;zero (0) · one (1) · two (2) · few (3) · many (6) · other (4, 5, 7)&lt;/td&gt;
&lt;td&gt;zero · one · two · few · many · other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Japanese, Chinese, Korean, Turkish&lt;/td&gt;
&lt;td&gt;Turkish: one (1) · other · others: other only&lt;/td&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details catch people out. French and Portuguese put &lt;strong&gt;0&lt;/strong&gt; in &lt;code&gt;one&lt;/code&gt;, so "0 fichier" is singular. Spanish, French, Italian, and Portuguese now have a &lt;code&gt;many&lt;/code&gt; category for exact millions ("1 millón &lt;em&gt;de&lt;/em&gt; usuarios"), so a translation with only &lt;code&gt;one&lt;/code&gt; and &lt;code&gt;other&lt;/code&gt; will fall back to &lt;code&gt;other&lt;/code&gt; there.&lt;/p&gt;

&lt;p&gt;Rules change between CLDR releases, so check against the runtime your users actually have instead of a table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PluralRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resolvedOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;pluralCategories&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ["few", "many", "one", "other"]&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PluralRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ordinal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "two"  →  22nd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Numbers, Dates, and Currency Inside Messages
&lt;/h2&gt;

&lt;p&gt;ICU also wraps the &lt;code&gt;Intl&lt;/code&gt; formatting APIs, so you can keep formatting inside the message where the translator can see it in context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"invoice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your invoice for {amount, number, ::currency/USD} is due {date, date, medium}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule to internalize: &lt;strong&gt;pass raw values, never pre-formatted strings.&lt;/strong&gt; If you call &lt;code&gt;amount.toFixed(2)&lt;/code&gt; before handing the value to your translation layer, you have permanently locked every locale into American decimal separators. Pass the &lt;code&gt;Number&lt;/code&gt;. Pass the &lt;code&gt;Date&lt;/code&gt;. Let the formatter decide. For formatting outside of messages, see our &lt;a href="https://www.babelize.co/blogs/javascript-intl-api-dates-numbers-currencies/" rel="noopener noreferrer"&gt;guide to the JavaScript Intl API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nesting and Rich Text
&lt;/h2&gt;

&lt;p&gt;Real interface copy has links and bold spans in the middle of sentences. The wrong instinct is to split the sentence into three keys — prefix, link text, suffix — and concatenate. That produces unreadable translation files and breaks in any language with different word order.&lt;/p&gt;

&lt;p&gt;Instead, keep the sentence whole and pass the markup as a function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// message&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terms&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;By continuing you accept our &amp;lt;link&amp;gt;Terms of Service&amp;lt;/link&amp;gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;// component&lt;/span&gt;
&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rich&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terms&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/terms"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The translator sees one coherent sentence and can move the link anywhere inside it. Your JSX stays in your code where it belongs.&lt;/p&gt;

&lt;p&gt;You can also nest constructs. This is legal and occasionally necessary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"shared"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{count, plural,
  one {{gender, select,
    female {She shared # file}
    other {They shared # file}
  }}
  other {{gender, select,
    female {She shared # files}
    other {They shared # files}
  }}
}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5c0zmt06ovh3u59fs27f.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5c0zmt06ovh3u59fs27f.webp" alt="Tree diagram of the nested " width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Nesting select inside plural multiplies the branches: two levels mean four full sentences to translate.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is also where readability dies. If a message needs two levels of nesting, that is usually a signal to split it into two separate UI strings instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes That Show Up in Code Review
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concatenating translated fragments.&lt;/strong&gt; &lt;code&gt;t("deleted") + " " + count + " " + t("items")&lt;/code&gt; is unfixable by a translator. One key, one sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusing a key in two places.&lt;/strong&gt; "Open" as a button and "Open" as a status are the same word in English and different words in German. Key by meaning, not by string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Omitting the &lt;code&gt;other&lt;/code&gt; branch.&lt;/strong&gt; It is the required fallback. A message without it throws at runtime in strict formatters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Putting HTML in the message string.&lt;/strong&gt; Raw &lt;code&gt;&amp;lt;b&amp;gt;&lt;/code&gt; tags invite XSS through translation files and break when a translator mangles a tag. Use rich-text callbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translating the ICU syntax itself.&lt;/strong&gt; A machine translator with no ICU awareness will happily translate the literal word &lt;code&gt;other&lt;/code&gt; or reorder the braces, producing a message that fails to parse. Validate ICU syntax in CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Validating ICU in CI
&lt;/h2&gt;

&lt;p&gt;Because ICU messages are strings, a broken one is not a compile error — it is a runtime crash on a page only your Polish users visit. Worth adding to your pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parse every message in every locale with the FormatJS parser and fail the build on syntax errors&lt;/li&gt;
&lt;li&gt;Check that the set of variables in each translation matches the source message — a missing &lt;code&gt;{count}&lt;/code&gt; is a silent content bug&lt;/li&gt;
&lt;li&gt;Assert that every plural message includes an &lt;code&gt;other&lt;/code&gt; branch&lt;/li&gt;
&lt;li&gt;Snapshot-render a few numbers (0, 1, 2, 5, 21, 101) per locale and eyeball the output once&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;a href="https://www.babelize.co/blogs/pseudo-localization-testing-guide/" rel="noopener noreferrer"&gt;pseudo-localization pass&lt;/a&gt; complements these checks: it exercises every message in a fake locale and exposes concatenated sentences and hardcoded strings before real translators see them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.babelize.co/cli/check" rel="noopener noreferrer"&gt;babelize check&lt;/a&gt; covers the first two from the command line: &lt;code&gt;npx @babelize/cli check&lt;/code&gt; reports broken ICU and plural forms and lost placeholders in every locale file. It only reads files, so it needs no account and no network access.&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Do Not Need ICU
&lt;/h2&gt;

&lt;p&gt;Most strings in a typical app are static labels with no variables. Those do not need ICU and wrapping them in it adds nothing. ICU earns its complexity specifically where a message contains a count, a name, a date, a currency, or a gendered reference — which in practice is a minority of your keys, and the source of most of your localization bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is ICU MessageFormat?
&lt;/h3&gt;

&lt;p&gt;ICU MessageFormat is a string syntax that lets a single translation key express plural rules, gender agreement, number formatting, and conditional text. It moves grammatical decisions out of application code and into the translation string, so each locale can supply its own plural categories and word order.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the ICU plural rules?
&lt;/h3&gt;

&lt;p&gt;ICU uses the Unicode CLDR plural categories zero, one, two, few, many, and other. Each language uses its own subset: English uses one and other, Polish and Russian use one, few, many, and other, Arabic uses all six, and Japanese uses only other. The other branch is required in every plural message.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between =0 and zero in an ICU plural?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;=0&lt;/code&gt; is an exact match that fires whenever the value is literally zero, in any language, and is used for empty-state copy. &lt;code&gt;zero&lt;/code&gt; is a grammatical plural category used by languages such as Arabic and Latvian, and it may not fire for the number 0 in languages that lack that category.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which i18n libraries support ICU MessageFormat?
&lt;/h3&gt;

&lt;p&gt;next-intl and react-intl (FormatJS) support ICU natively. i18next supports it through the i18next-icu plugin. ICU is also supported on Android, iOS, Java, and most translation management platforms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I put HTML inside my ICU messages?
&lt;/h3&gt;

&lt;p&gt;No. Raw HTML in translation files invites XSS and breaks when a translator mangles a tag. Use tag placeholders with a rich-text callback so the markup stays in your components while the translator keeps control of the full sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Short Version
&lt;/h2&gt;

&lt;p&gt;Pluralization is grammar, and grammar belongs to the language, not to your &lt;code&gt;if&lt;/code&gt; statement. ICU MessageFormat exists so that a translator who has never seen your codebase can produce a correct Polish sentence without asking you to ship new code. Adopt it for every message containing a variable, validate the syntax in CI, and pass raw numbers and dates rather than formatted strings.&lt;/p&gt;

&lt;p&gt;Once ICU messages are in place, keeping every locale in sync as your source strings change becomes the next bottleneck — which is exactly the part &lt;a href="https://www.babelize.co/" rel="noopener noreferrer"&gt;Babelize&lt;/a&gt; automates, detecting new and changed keys and generating ICU-valid translations across your locale files through a Git-native workflow. See the &lt;a href="https://docs.babelize.co/sdk/icu" rel="noopener noreferrer"&gt;Babelize ICU documentation&lt;/a&gt; for how messages are validated.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed by the Babelize team. Every formatted output was checked with intl-messageformat.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.babelize.co/blogs/icu-messageformat-plurals-guide/" rel="noopener noreferrer"&gt;Babelize blog&lt;/a&gt;. Disclosure: Babelize is our product, an AI localization tool for Next.js, React and Vue apps: it generates and syncs your JSON locale files from your repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>javascript</category>
      <category>localization</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Lokalise and Crowdin Alternatives for Developers (2026)</title>
      <dc:creator>Mohit Sapat</dc:creator>
      <pubDate>Sat, 17 Oct 2026 12:49:48 +0000</pubDate>
      <link>https://dev.to/babelize/lokalise-and-crowdin-alternatives-for-developers-2026-4121</link>
      <guid>https://dev.to/babelize/lokalise-and-crowdin-alternatives-for-developers-2026-4121</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The main Lokalise and Crowdin alternatives are Phrase (enterprise suite), Tolgee (open source, in-context editing, free tier), Locize (i18next, from $7/mo) and Weblate (open source, Git-native). Crowdin, Tolgee and Locize have free plans; Lokalise and Phrase offer only trials. If your strings are still hardcoded, you need a code tool first: i18next-cli for a free partial pass, or Babelize (our product) for the whole repo as a pull request.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Lokalise and Crowdin are built for localization teams: translators, reviewers, vendors and workflows. If you are a developer who mostly wants your app to speak more languages, you may be paying for a translator workspace you never open, or discover that the tool assumes your strings already live in translation files. This guide compares seven alternatives on what developers actually care about, with prices checked on October 16, 2026 and Lingo.dev added on October 28.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, Know Which Problem You Have
&lt;/h2&gt;

&lt;p&gt;Localization tools solve one of two different problems, and most comparisons blur them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Managing translations.&lt;/strong&gt; Your app already uses &lt;code&gt;t("checkout.title")&lt;/code&gt; and has locale files. You need a place where translators and machine translation fill in other languages, and a sync that brings them back to your repo. This is what a translation management system (TMS) does: Lokalise, Crowdin, Phrase, Tolgee, Locize and Weblate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internationalizing the code.&lt;/strong&gt; Your app still has &lt;code&gt;&amp;lt;h1&amp;gt;Welcome back&amp;lt;/h1&amp;gt;&lt;/code&gt; hardcoded in hundreds of components. Before any TMS can help, someone has to find those strings, replace them with translation calls, set up the i18n library and create the locale files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every TMS below starts at step two. Their own documentation describes importing translation files or keys that already exist. If you are at step one, that is the work to solve first.&lt;/p&gt;

&lt;p&gt;The two are not either-or. A common path is to internationalize the code first, then add a TMS only if and when you bring in human translators. The locale files are plain JSON or gettext either way, so any of the tools below can take over from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Alternatives at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Free plan&lt;/th&gt;
&lt;th&gt;Cheapest paid plan&lt;/th&gt;
&lt;th&gt;Open source&lt;/th&gt;
&lt;th&gt;Opens PRs in your repo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lokalise&lt;/td&gt;
&lt;td&gt;No (14-day trial)&lt;/td&gt;
&lt;td&gt;Explorer: $169/mo, or $149/mo billed yearly&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crowdin&lt;/td&gt;
&lt;td&gt;Yes: 60K hosted words, 1 private project&lt;/td&gt;
&lt;td&gt;Pro: $59/mo, or from $50/mo billed yearly&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phrase&lt;/td&gt;
&lt;td&gt;No (14-day trial)&lt;/td&gt;
&lt;td&gt;$525/mo billed yearly for a developer team plan&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tolgee&lt;/td&gt;
&lt;td&gt;Yes: 30,000 words, 3 seats&lt;/td&gt;
&lt;td&gt;Translate: €69/mo ($79), or €58/mo billed yearly&lt;/td&gt;
&lt;td&gt;Yes, self-hostable&lt;/td&gt;
&lt;td&gt;Through the CLI in CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Locize&lt;/td&gt;
&lt;td&gt;Yes: 2,000 words, 2 languages&lt;/td&gt;
&lt;td&gt;Starter: $7/mo&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Through GitHub Actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weblate&lt;/td&gt;
&lt;td&gt;Self-hosting free; hosted free for public projects&lt;/td&gt;
&lt;td&gt;€47/mo (10,000 hosted strings)&lt;/td&gt;
&lt;td&gt;Yes (GPLv3+)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lingo.dev&lt;/td&gt;
&lt;td&gt;Yes: Sandbox, 100K tokens a day, LLM costs passed through&lt;/td&gt;
&lt;td&gt;Production: $99/mo plus usage&lt;/td&gt;
&lt;td&gt;Yes: CLI and compiler (Apache-2.0)&lt;/td&gt;
&lt;td&gt;Yes, through its GitHub App&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Babelize&lt;/td&gt;
&lt;td&gt;Yes: 5 projects, 10,000 AI credits a month&lt;/td&gt;
&lt;td&gt;Starter: $9/mo, or $7/mo billed yearly&lt;/td&gt;
&lt;td&gt;No (the Elements UI library is)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prices are the vendors' public list prices on October 16, 2026, and they change. The units also differ: hosted words, hosted strings, seats or characters, so compare the limit that matters for your app, not just the dollar figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crowdin: The Generous Free Plan
&lt;/h2&gt;

&lt;p&gt;Crowdin's free plan is the most usable in this list: 60,000 hosted words, one private project, unlimited public projects and unlimited translators, with Crowdin AI and machine translation included. Hosted words are words multiplied by target languages, so 60,000 is about 6,000 source words in ten languages. The catch, from its pricing page: users on the free plan donate translations to Crowdin's translation memory. Open-source projects can apply for a free license if they meet conditions such as an OSI license, public code and an active community.&lt;/p&gt;

&lt;p&gt;The GitHub integration syncs your source and translation files and opens pull requests from an &lt;code&gt;l10n_&lt;/code&gt; branch. &lt;strong&gt;Best for:&lt;/strong&gt; projects that bring in many outside or volunteer translators. (&lt;a href="https://crowdin.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;, &lt;a href="https://support.crowdin.com/github-integration/" rel="noopener noreferrer"&gt;GitHub integration&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Lokalise: Strong Workflows, No Free Plan
&lt;/h2&gt;

&lt;p&gt;Lokalise has no free plan, only a 14-day trial. The entry Explorer plan is $169 a month ($149 billed yearly) for 5 projects and 10 languages, with unlimited translator and reviewer seats, 5 advanced seats, and a quota of machine-translated words from DeepL and Google Translate. Its GitHub integration pulls your language files and opens pull requests. The strengths are on the team side: translation memory, glossaries, tasks, in-context editors and over-the-air SDKs that update mobile apps without a release. (&lt;a href="https://lokalise.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;, &lt;a href="https://docs.lokalise.com/en/articles/1684090-github" rel="noopener noreferrer"&gt;GitHub integration&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Phrase: Enterprise Depth
&lt;/h2&gt;

&lt;p&gt;Phrase Strings is part of a suite that also translates documents. Its cheapest plan that includes Strings seats for a team is $525 a month billed yearly. It syncs with GitHub, GitLab and Bitbucket and exports translations as pull requests. If you need vendor management, quality review and one platform for software and documents, it is a serious option; for a small team it is a lot of tool. (&lt;a href="https://phrase.com/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Tolgee: Open Source With In-Context Editing
&lt;/h2&gt;

&lt;p&gt;Tolgee is open source (Apache-2.0, with enterprise parts licensed separately) and can be self-hosted for free with up to 10 seats. The cloud has a free plan of 30,000 words and 3 seats. Its standout feature is in-context editing: hold Alt, click a string in your running app and translate it on the spot. Its CLI extracts keys already used through the Tolgee SDK for React, Vue and Svelte, so you still write the translation calls yourself. (&lt;a href="https://tolgee.io/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;, &lt;a href="https://docs.tolgee.io/tolgee-cli/extraction/syncing-strings" rel="noopener noreferrer"&gt;CLI extraction&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Locize: Cheapest Paid Plan, Built for i18next
&lt;/h2&gt;

&lt;p&gt;Locize comes from the creators of i18next and is the cheapest paid option here at $7 a month for 15,000 words and 5 languages. Its distinguishing feature is delivery through a CDN, so translation updates can reach users without a redeploy. If you already use i18next it fits naturally. The same team publishes i18next-cli, covered below. (&lt;a href="https://www.locize.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Weblate: Open Source and Git-Native
&lt;/h2&gt;

&lt;p&gt;Weblate is a web TMS built on version control, licensed GPLv3+ and free to self-host. Hosted plans start at €47 a month for 10,000 hosted strings, where hosted strings are source strings times the number of languages plus one. Public open-source projects can use the free Libre plan. It opens merge requests with translation changes and supports many engines, from DeepL to self-hosted Ollama. (&lt;a href="https://weblate.org/en/hosting/" rel="noopener noreferrer"&gt;hosting&lt;/a&gt;, &lt;a href="https://docs.weblate.org/en/latest/vcs.html" rel="noopener noreferrer"&gt;version control&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Lingo.dev: AI Translation Wired Into CI
&lt;/h2&gt;

&lt;p&gt;Lingo.dev is built around AI translation rather than a translator workspace. Its CLI pushes your locale files to a translation "engine" (your choice of LLM, plus brand voice, glossary and AI review), and its GitHub App opens or updates a translation pull request on every push to the default branch. The CLI is open source under Apache-2.0 and handles eighteen file formats, from JSON and YAML to PO, XLIFF and Flutter ARB. The free Sandbox plan allows 100K tokens a day; Production starts at $99 a month plus usage, and LLM costs are passed through without markup. (&lt;a href="https://lingo.dev/en/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;, &lt;a href="https://github.com/lingodotdev/lingo.dev" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Like Lokalise and Crowdin, the CLI translates files that already exist. For hardcoded strings, Lingo.dev also has a compiler, covered in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  If Your Strings Are Still Hardcoded
&lt;/h2&gt;

&lt;p&gt;None of the platforms above will turn &lt;code&gt;&amp;lt;h1&amp;gt;Welcome back&amp;lt;/h1&amp;gt;&lt;/code&gt; into a translation call across your codebase. These tools do part or all of that job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;i18next-cli &lt;code&gt;instrument&lt;/code&gt;&lt;/strong&gt; (free, MIT). It scans source code for hardcoded user-facing strings and wraps them in i18next calls. Its README says it is heuristic and will not catch every case, and its docs cover React and JavaScript/TypeScript. A good free start for React apps. (&lt;a href="https://github.com/i18next/i18next-cli" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lingo.dev Compiler&lt;/strong&gt; (free, open source). It reads your JSX at build time and injects AI translations without changing your components, so there are no keys or &lt;code&gt;t()&lt;/code&gt; calls in your code. It supports Next.js (App Router) and Vite with React and needs an LLM provider. Its docs currently say it is in alpha, "unstable, not recommended for production use". (&lt;a href="https://lingo.dev/en/docs/react/compiler" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editor extensions.&lt;/strong&gt; Lokalise's i18n Ally has experimental "extract hard-coded strings" commands that work one file at a time, and Crowdin's VS Code extension extracts a selected string to a key you type. Useful for touch-ups, slow for a whole app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Babelize&lt;/strong&gt; (our product). It scans a React, Next.js, Django or static HTML repo (Vue and React Native in beta), replaces hardcoded strings with translation calls (react-i18next, next-intl or Django gettext), writes the locale files, translates them into the languages you pick, and opens a pull request you review. It also runs locally as &lt;code&gt;npx @babelize/cli translate&lt;/code&gt;. Here is &lt;a href="https://www.babelize.co/blogs/how-babelize-localizes-nextjs-app/" rel="noopener noreferrer"&gt;how it works on a Next.js app&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is what that looks like on a React component. Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Welcome&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Welcome back&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the pull request, the component reads its text from translation files, and each language has its own file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useTranslation&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-i18next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Welcome&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTranslation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;welcome.welcomeBack&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;locales/fr.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"welcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"welcomeBack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bon retour"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In fairness, here is what Babelize does not do yet: it writes JSON and gettext &lt;code&gt;.po&lt;/code&gt; files only (no XLIFF, YAML, ARB, iOS or Android string files), it has no marketplace of human translators and no in-context editor, and it does not push translation updates to mobile apps over the air. If those are what you need, a TMS above is the better fit, and the two can work together: Babelize internationalizes the code and produces the locale files, and a TMS can manage them from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Worked Example: 500 Strings, 5 Languages
&lt;/h2&gt;

&lt;p&gt;List prices are hard to compare because each tool counts differently. Take a typical small SaaS app: 500 UI strings averaging 5 words and 30 characters, so about 2,500 source words and 15,000 source characters, translated into 5 languages. Using each vendor's own counting rules:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;How it counts&lt;/th&gt;
&lt;th&gt;This app&lt;/th&gt;
&lt;th&gt;Plan that fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Crowdin&lt;/td&gt;
&lt;td&gt;Hosted words × target languages&lt;/td&gt;
&lt;td&gt;12,500 hosted words&lt;/td&gt;
&lt;td&gt;Free (60K)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tolgee&lt;/td&gt;
&lt;td&gt;Hosted words&lt;/td&gt;
&lt;td&gt;12,500&lt;/td&gt;
&lt;td&gt;Free (30,000)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Locize&lt;/td&gt;
&lt;td&gt;Words and languages&lt;/td&gt;
&lt;td&gt;2,500 words, 5 languages&lt;/td&gt;
&lt;td&gt;Starter, $7/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weblate&lt;/td&gt;
&lt;td&gt;Source strings × (languages + 1)&lt;/td&gt;
&lt;td&gt;3,000 hosted strings&lt;/td&gt;
&lt;td&gt;€47/mo hosted, or free self-hosted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lokalise&lt;/td&gt;
&lt;td&gt;Seats, projects, languages&lt;/td&gt;
&lt;td&gt;1 project, 5 languages&lt;/td&gt;
&lt;td&gt;Explorer, $149–169/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phrase&lt;/td&gt;
&lt;td&gt;Seats and managed words&lt;/td&gt;
&lt;td&gt;within limits&lt;/td&gt;
&lt;td&gt;$525/mo billed yearly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Babelize&lt;/td&gt;
&lt;td&gt;Source characters × target languages&lt;/td&gt;
&lt;td&gt;75,000 credits for the first full run&lt;/td&gt;
&lt;td&gt;Growth, $29/mo, or Starter plus your own LLM key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things the table does not show. First, the TMS prices assume your strings are already in translation files; none of them does the work of getting them there. Second, Babelize's first run is the expensive one: it keeps a lockfile, so later runs only translate new or changed strings. If you change 50 strings in a month, that is about 7,500 credits, which fits the free plan. A TMS, by contrast, charges for the words it hosts every month.&lt;/p&gt;

&lt;p&gt;So for this app, Crowdin or Tolgee is cheapest for managing translations, and Babelize is the only option here that also does the code work.&lt;/p&gt;

&lt;p&gt;Still choosing the i18n library itself? &lt;a href="https://www.babelize.co/blogs/best-i18n-libraries-2027/" rel="noopener noreferrer"&gt;The best i18n libraries for every framework in 2027&lt;/a&gt; picks one per framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strings still hardcoded:&lt;/strong&gt; start with code instrumentation (i18next-cli for a free partial pass in React, or Babelize for the whole repo as a PR), then decide whether you need a TMS at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volunteer or outside translators:&lt;/strong&gt; Crowdin or Weblate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open source and self-hosted:&lt;/strong&gt; Tolgee or Weblate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already on i18next, small budget:&lt;/strong&gt; Locize.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI translation in CI, files already extracted:&lt;/strong&gt; Lingo.dev, or Babelize if you also want the extraction done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A localization team with reviewers and vendors:&lt;/strong&gt; Lokalise or Phrase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you choose, the locale files are plain JSON or gettext in your repo, so you are not locked in. See &lt;a href="https://www.babelize.co/blogs/json-translation-files-explained/" rel="noopener noreferrer"&gt;JSON translation files explained&lt;/a&gt; for the format, &lt;a href="https://www.babelize.co/blogs/translation-memory-explained/" rel="noopener noreferrer"&gt;translation memory explained&lt;/a&gt; for what a TMS adds, and &lt;a href="https://www.babelize.co/blogs/saas-localization-guide/" rel="noopener noreferrer"&gt;the SaaS localization guide&lt;/a&gt; for the full process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the best Crowdin alternative for developers?
&lt;/h3&gt;

&lt;p&gt;It depends on where your strings are. If they are already in translation files, Weblate and Tolgee are the closest open-source alternatives, and Lingo.dev if you want AI translation through pull requests. If your strings are still hardcoded in components, a TMS will not help yet: Babelize extracts them and opens a reviewable PR, and the Lingo.dev Compiler translates at build time but is still in alpha.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the best Lokalise alternative?
&lt;/h3&gt;

&lt;p&gt;For a team that needs translator workflows, Phrase is the closest match and Crowdin the cheaper one, with a free plan. For a developer who mainly wants the app translated, Tolgee, Locize, Lingo.dev or Babelize cost far less: Lokalise starts at $149 a month billed yearly, while those start free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a free alternative to Lokalise?
&lt;/h3&gt;

&lt;p&gt;Yes. Crowdin has a free plan with 60,000 hosted words and one private project, Tolgee's cloud is free up to 30,000 words and 3 seats, Locize is free for 2,000 words, and Weblate and Tolgee can be self-hosted for free. Lokalise itself only offers a 14-day trial.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the cheapest Crowdin alternative?
&lt;/h3&gt;

&lt;p&gt;Among paid plans, Locize starts at $7 a month and Babelize at $9 a month ($7 billed yearly). Self-hosting Tolgee or Weblate costs only your server, if you are comfortable running it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do Lokalise or Crowdin find hardcoded strings in my code?
&lt;/h3&gt;

&lt;p&gt;Not as a platform feature. Both manage translation files and keys that already exist. Lokalise's i18n Ally extension can extract strings experimentally, one file at a time, and Crowdin's VS Code extension extracts a selected string. To wrap strings across a whole repo, use a code instrumentation tool such as i18next-cli or Babelize.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which localization tools are open source?
&lt;/h3&gt;

&lt;p&gt;Tolgee (Apache-2.0 for its core) and Weblate (GPLv3+) are open source and self-hostable. Lokalise, Crowdin, Phrase and Locize are proprietary services.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed by the Babelize team. Prices were checked on each vendor's pricing page on October 16, 2026 (Lingo.dev on October 28) and are linked in the post.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.babelize.co/blogs/lokalise-crowdin-alternatives-for-developers/" rel="noopener noreferrer"&gt;Babelize blog&lt;/a&gt;. Disclosure: Babelize is our product. It is included in the comparison and marked as ours, along with what it does not do yet.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Translating Your SaaS into German: What Actually Changes</title>
      <dc:creator>Mohit Sapat</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:00:21 +0000</pubDate>
      <link>https://dev.to/babelize/translating-your-saas-into-german-what-actually-changes-4pe3</link>
      <guid>https://dev.to/babelize/translating-your-saas-into-german-what-actually-changes-4pe3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Translating a SaaS into German changes more than the words: UI text runs about 30% longer and short labels often double or triple, you must choose formal Sie or informal du for every string, numbers and dates use German formats (1.299,00 €, 13.10.2026), and you need German legal pages (Impressum under the DDG, privacy policy, cookie consent), VAT-inclusive consumer prices, and payment methods such as SEPA Direct Debit and invoice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Germany is usually the first non-English market a SaaS product goes after, and the translation is the easy part. Strings get longer, the product has to choose how formally it talks, numbers and dates flip their separators, and the legal and billing pages need more than a translator. This guide covers what actually changes, with measurements from real open-source apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  German Text Is About 30% Longer, and Short Strings Grow the Most
&lt;/h2&gt;

&lt;p&gt;"German is longer" is common knowledge. How much longer is worth measuring, because it decides how much room your buttons need. We compared the English and German UI strings of two widely used open-source apps, &lt;a href="https://github.com/excalidraw/excalidraw" rel="noopener noreferrer"&gt;Excalidraw&lt;/a&gt; and &lt;a href="https://github.com/mastodon/mastodon" rel="noopener noreferrer"&gt;Mastodon&lt;/a&gt;, character by character (placeholders and tags removed, untranslated strings skipped):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Excalidraw&lt;/th&gt;
&lt;th&gt;Mastodon&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Translated strings compared&lt;/td&gt;
&lt;td&gt;566&lt;/td&gt;
&lt;td&gt;1,574&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total length, German vs English&lt;/td&gt;
&lt;td&gt;+28%&lt;/td&gt;
&lt;td&gt;+29%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strings that got longer&lt;/td&gt;
&lt;td&gt;81%&lt;/td&gt;
&lt;td&gt;79%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strings at least 1.5× as long&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short strings (1–10 characters): median growth&lt;/td&gt;
&lt;td&gt;+27%&lt;/td&gt;
&lt;td&gt;+50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short strings: 1 in 10 grows by at least&lt;/td&gt;
&lt;td&gt;+88%&lt;/td&gt;
&lt;td&gt;+183%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The averages are manageable. The tail is what breaks layouts, and it sits in the shortest strings: the labels on buttons, tabs, and menu items, where you have the least room. Some real pairs from those files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Undo → &lt;strong&gt;Rückgängig machen&lt;/strong&gt; (4.2×)&lt;/li&gt;
&lt;li&gt;Unmute → &lt;strong&gt;Stummschaltung aufheben&lt;/strong&gt; (3.8×)&lt;/li&gt;
&lt;li&gt;Retry → &lt;strong&gt;Erneut versuchen&lt;/strong&gt; (3.2×)&lt;/li&gt;
&lt;li&gt;Edit → &lt;strong&gt;Bearbeiten&lt;/strong&gt; (2.5×)&lt;/li&gt;
&lt;li&gt;Move canvas → &lt;strong&gt;Zeichenfläche verschieben&lt;/strong&gt; (2.3×)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What to do about it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never give text a fixed width.&lt;/strong&gt; Let buttons and tabs grow, and decide in advance what happens when they cannot: wrap, truncate with a tooltip, or move into an overflow menu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget for 3× on labels under 10 characters&lt;/strong&gt; and about 1.5× on sentences, not a flat 30%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test before you translate.&lt;/strong&gt; A &lt;a href="https://www.babelize.co/blogs/pseudo-localization-testing-guide/" rel="noopener noreferrer"&gt;pseudo-locale&lt;/a&gt; that pads every string by 40% shows where the layout breaks without waiting for a translator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on hyphenation for long compounds.&lt;/strong&gt; German glues nouns together ("Zahlungsmethodenverwaltung"), and a single word can be wider than a narrow column.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Works only when the page declares its language: &amp;lt;html lang="de"&amp;gt; */&lt;/span&gt;
&lt;span class="nc"&gt;.card-title&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.table-header&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;hyphens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow-wrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;break-word&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hyphens: auto&lt;/code&gt; uses the browser's German dictionary, so it only works when the document or element has &lt;code&gt;lang="de"&lt;/code&gt;. That is one more reason to set the &lt;code&gt;lang&lt;/code&gt; attribute from the active locale instead of hardcoding &lt;code&gt;en&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Du or Sie: Pick One Before You Translate Anything
&lt;/h2&gt;

&lt;p&gt;German has two forms of "you": informal &lt;em&gt;du&lt;/em&gt; and formal &lt;em&gt;Sie&lt;/em&gt;. Every sentence that addresses the user commits to one, so the choice affects thousands of strings, and switching later means retranslating all of them.&lt;/p&gt;

&lt;p&gt;Developer and consumer tools have moved toward &lt;em&gt;du&lt;/em&gt;. In the German files we measured, Excalidraw uses informal forms (&lt;em&gt;du&lt;/em&gt;, &lt;em&gt;dein&lt;/em&gt;, &lt;em&gt;dich&lt;/em&gt;) 60 times and formal &lt;em&gt;Sie&lt;/em&gt; zero times; Mastodon uses informal forms 252 times against a handful of formal ones. Products that sell to banks, insurers, law firms, or the public sector usually stay with &lt;em&gt;Sie&lt;/em&gt;, because that is how their customers write to each other.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match how your customers talk to each other&lt;/strong&gt;, not how your English copy sounds. Casual English does not automatically mean &lt;em&gt;du&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the decision down&lt;/strong&gt; in your style guide and glossary, and give it to every translator and every AI prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never mix the two.&lt;/strong&gt; A product that says &lt;em&gt;du&lt;/em&gt; on the dashboard and &lt;em&gt;Sie&lt;/em&gt; in billing emails reads as two different companies. Search your German files for both forms before each release.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Numbers, Dates, and Currency Flip
&lt;/h2&gt;

&lt;p&gt;German swaps the roles of the comma and the period, writes the currency symbol after the amount, and puts the day before the month. The browser already knows all of this, as long as you format values with &lt;code&gt;Intl&lt;/code&gt; instead of string templates. Every output below was generated by &lt;code&gt;Intl&lt;/code&gt; in Node.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1234567.891&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "1.234.567,891"   (en-US: "1,234,567.891")&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1299&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "1.299,00 €"&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;percent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maximumFractionDigits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.195&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "19,5 %"   (note the space before %)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-13T14:05:00Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dateStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Europe/Berlin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "13.10.2026"&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ListFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;conjunction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Rechnung&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SEPA-Lastschrift&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PayPal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// "Rechnung, SEPA-Lastschrift und PayPal"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"German" is also more than one locale. Switzerland and Austria format differently, so do not let &lt;code&gt;de-DE&lt;/code&gt; stand in for every German speaker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-CH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CHF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1299.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "CHF 1'299.50"&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-AT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1299.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "€ 1.299,50"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more details: German uses the 24-hour clock in UI text, and the week starts on Monday, which matters for calendars, date pickers, and weekly reports. Our &lt;a href="https://www.babelize.co/blogs/javascript-intl-api-dates-numbers-currencies/" rel="noopener noreferrer"&gt;Intl API guide&lt;/a&gt; covers the formatting APIs in depth, and the &lt;a href="https://www.babelize.co/blogs/locale-codes-bcp-47-explained/" rel="noopener noreferrer"&gt;locale codes guide&lt;/a&gt; explains when to use &lt;code&gt;de&lt;/code&gt; versus &lt;code&gt;de-DE&lt;/code&gt;, &lt;code&gt;de-AT&lt;/code&gt;, or &lt;code&gt;de-CH&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typography That Translators Get Right and Code Gets Wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quotation marks&lt;/strong&gt; are „like this“ (low-high), not "like this". If your code wraps user content in quotes, the quotes belong in the translation string, not in the component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nouns are capitalized&lt;/strong&gt;, so CSS &lt;code&gt;text-transform: capitalize&lt;/code&gt; on English-style title case will mangle German. Let the translation decide capitalization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentence fragments do not survive.&lt;/strong&gt; "Delete" + " " + itemName works in English; German often needs the object before the verb. Translate whole sentences with placeholders, using &lt;a href="https://www.babelize.co/blogs/icu-messageformat-plurals-guide/" rel="noopener noreferrer"&gt;ICU MessageFormat&lt;/a&gt; for counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gender-inclusive language&lt;/strong&gt; is a live style choice in German. Teams pick doubled forms ("Nutzerinnen und Nutzer"), neutral participles ("Nutzende"), or symbols ("Nutzer*innen"). Neutral forms are the least controversial and the easiest to keep short. As with &lt;em&gt;du&lt;/em&gt; and &lt;em&gt;Sie&lt;/em&gt;, pick one and apply it everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Legal Pages Are Not a Translation Task
&lt;/h2&gt;

&lt;p&gt;Some German requirements have no English equivalent, so translating your existing legal pages will not cover them. The usual list for a SaaS product with German customers looks like this; have a German lawyer review it, because this is a summary, not legal advice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Impressum (legal notice).&lt;/strong&gt; Commercial websites aimed at Germany must name the operator, a postal address, and contact details. Since May 14, 2024 the legal basis is &lt;a href="https://www.gesetze-im-internet.de/ddg/__5.html" rel="noopener noreferrer"&gt;§ 5 of the Digitale-Dienste-Gesetz (DDG)&lt;/a&gt;, which replaced the Telemediengesetz. An Impressum that still cites "§ 5 TMG" is out of date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Datenschutzerklärung (privacy policy)&lt;/strong&gt; under the GDPR (DSGVO in German), in German, covering every tool that processes personal data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie consent&lt;/strong&gt; under the TDDDG, the renamed TTDSG: non-essential cookies and trackers need consent before they load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A data processing agreement&lt;/strong&gt; (Auftragsverarbeitungsvertrag, AVV). German business customers will ask for one before signing, along with where your data is hosted. Have it ready instead of drafting it during a deal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pricing and Billing Change Too
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Show the right price.&lt;/strong&gt; Prices shown to consumers must include VAT, which is 19% for most digital services in Germany. Business-only pricing pages can show net prices, marked "zzgl. MwSt." (plus VAT).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collect VAT IDs&lt;/strong&gt; for business customers in other EU countries so you can invoice under the reverse-charge rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offer the payment methods Germans use.&lt;/strong&gt; German shoppers rely on PayPal, SEPA Direct Debit, and paying on invoice (Kauf auf Rechnung) much more than shoppers in most Western markets, and cards make up a comparatively small share of online purchases. For B2B SaaS, accepting SEPA Direct Debit and paying an invoice by bank transfer removes a common blocker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Localize the invoice itself.&lt;/strong&gt; A German customer's accountant expects German labels, the correct VAT breakdown, and dates and amounts in German format.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A Practical Order of Work
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Internationalize first.&lt;/strong&gt; Move every user-facing string into locale files and format every number and date with &lt;code&gt;Intl&lt;/code&gt;. Translating hardcoded strings is wasted work; see &lt;a href="https://www.babelize.co/blogs/i18n-vs-l10n/" rel="noopener noreferrer"&gt;i18n vs l10n&lt;/a&gt; for the difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a pseudo-locale&lt;/strong&gt; with 40% padding and fix the layouts that break, especially buttons, tabs, and table headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide &lt;em&gt;du&lt;/em&gt; or *Sie&lt;/strong&gt;*, gender-inclusive style, and a glossary of product terms that stay in English.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translate the UI&lt;/strong&gt;, with a German speaker reviewing at least onboarding, billing, and error messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle legal and billing separately&lt;/strong&gt;: Impressum, privacy policy, consent, AVV, VAT display, and payment methods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launch the German locale behind its own URL&lt;/strong&gt; (&lt;code&gt;/de/&lt;/code&gt;) with &lt;a href="https://www.babelize.co/blogs/hreflang-multilingual-seo-guide/" rel="noopener noreferrer"&gt;hreflang&lt;/a&gt;, so German searchers land on German pages.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If German is your first market after English, our guide to &lt;a href="https://www.babelize.co/blogs/choose-your-first-saas-languages/" rel="noopener noreferrer"&gt;choosing your first SaaS languages&lt;/a&gt; covers how to sequence the next ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How much longer is German text than English?
&lt;/h3&gt;

&lt;p&gt;In the English and German UI strings of Excalidraw and Mastodon, German was 28 to 29% longer in total, about 80% of strings got longer, and a quarter to a third grew by 50% or more. Short labels grow the most: one in ten short Mastodon labels grew by 183% or more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should a SaaS product use du or Sie in German?
&lt;/h3&gt;

&lt;p&gt;Developer and consumer tools increasingly use du, while products for conservative industries such as finance, insurance, law, and the public sector usually use Sie. Match how your customers write to each other, document the choice, and never mix the two forms in one product.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I format prices and dates for German users?
&lt;/h3&gt;

&lt;p&gt;Use the Intl APIs with the de-DE locale instead of string templates. They produce 1.299,00 € for prices, 13.10.2026 for dates, and 19,5 % for percentages, and they handle Austrian (de-AT) and Swiss (de-CH) conventions too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a SaaS website need an Impressum for Germany?
&lt;/h3&gt;

&lt;p&gt;Commercial websites aimed at German users generally need one. Since May 14, 2024 the requirement is in § 5 of the Digitale-Dienste-Gesetz (DDG), which replaced § 5 of the Telemediengesetz (TMG). Have a German lawyer confirm what yours must contain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which payment methods should a SaaS offer in Germany?
&lt;/h3&gt;

&lt;p&gt;Beyond cards, offer SEPA Direct Debit and payment by invoice or bank transfer, and consider PayPal for self-serve plans. German buyers use these methods far more than shoppers in most other Western markets.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed by the Babelize team. The length and du/Sie figures were measured on Excalidraw's and Mastodon's own translation files, and the format examples were generated with Node's Intl.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.babelize.co/blogs/translate-saas-into-german/" rel="noopener noreferrer"&gt;Babelize blog&lt;/a&gt;. Babelize is an AI localization tool for Next.js, React and Django apps: it marks strings, keeps your locale files in sync, and opens the changes as a pull request.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>i18n</category>
      <category>localization</category>
      <category>webdev</category>
    </item>
    <item>
      <title>next-intl vs react-i18next: Which React i18n Library Should You Use?</title>
      <dc:creator>Mohit Sapat</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:44:03 +0000</pubDate>
      <link>https://dev.to/babelize/next-intl-vs-react-i18next-which-react-i18n-library-should-you-use-36mj</link>
      <guid>https://dev.to/babelize/next-intl-vs-react-i18next-which-react-i18n-library-should-you-use-36mj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Use next-intl for Next.js App Router apps: it translates in Server Components, ships locale routing and middleware, and uses ICU MessageFormat out of the box. Use react-i18next (i18next) when you need React Native, other frameworks, or a shared Node.js backend, or already rely on the i18next plugin ecosystem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you're adding internationalization to a React or Next.js app, you will hit the same fork in the road almost immediately: next-intl or react-i18next? Both are mature, actively maintained, and used in production by thousands of teams. They just make very different assumptions about where your app runs and who renders your text.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Short Answer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use next-intl if your app is a Next.js App Router project.&lt;/strong&gt; It was designed around Server Components, locale-aware routing, and ICU MessageFormat, so the common path needs very little glue code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use react-i18next if your app is not Next.js-only&lt;/strong&gt; — a Vite SPA, React Native, Remix, an Electron app, or a monorepo that shares translations across web and mobile — or if your team already runs on i18next and its plugin ecosystem.&lt;/p&gt;

&lt;p&gt;The rest of this guide explains why, with real setup code for both, a feature-by-feature comparison, the pitfalls that catch teams in production, and what a migration looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  At a Glance (October 2026)
&lt;/h2&gt;

&lt;p&gt;Current releases and npm downloads, pulled from the npm registry on October 13, 2026. Download counts include installs pulled in by other packages, so read them as a measure of reach, not of how many apps use each library directly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;next-intl&lt;/th&gt;
&lt;th&gt;react-i18next + i18next&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Latest version&lt;/td&gt;
&lt;td&gt;4.14.9&lt;/td&gt;
&lt;td&gt;react-i18next 17.0.16 · i18next 26.4.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly npm downloads&lt;/td&gt;
&lt;td&gt;~7.1M&lt;/td&gt;
&lt;td&gt;~17.9M (react-i18next) · ~24.1M (i18next)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on&lt;/td&gt;
&lt;td&gt;Next.js 12–16 only&lt;/td&gt;
&lt;td&gt;Any React 16.8+ app, React Native, Node.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message format&lt;/td&gt;
&lt;td&gt;ICU MessageFormat&lt;/td&gt;
&lt;td&gt;i18next JSON (ICU via &lt;code&gt;i18next-icu&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Components&lt;/td&gt;
&lt;td&gt;Built in (&lt;code&gt;getTranslations&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Per-request i18next instance; hooks are client-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Locale routing&lt;/td&gt;
&lt;td&gt;Built in (&lt;code&gt;defineRouting&lt;/code&gt;, &lt;code&gt;proxy.ts&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Bring your own&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What Are next-intl and react-i18next?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  react-i18next
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;react-i18next&lt;/strong&gt; is the official React binding for &lt;strong&gt;i18next&lt;/strong&gt;, a framework-agnostic internationalization core that has been around for over a decade. i18next itself knows nothing about React; react-i18next adds hooks (&lt;code&gt;useTranslation&lt;/code&gt;), a &lt;code&gt;Trans&lt;/code&gt; component for rich text, and a provider. Around that core sits a large ecosystem: language detectors, HTTP and filesystem backends, caching layers, an ICU plugin, and integrations with most &lt;a href="https://www.babelize.co/blogs/lokalise-crowdin-alternatives-for-developers/" rel="noopener noreferrer"&gt;translation management systems&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Because the core is framework-agnostic, the same i18next configuration and the same translation files can power a React web app, a React Native app, and a Node.js backend that sends localized emails.&lt;/p&gt;

&lt;h3&gt;
  
  
  next-intl
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;next-intl&lt;/strong&gt; is built specifically for &lt;strong&gt;Next.js&lt;/strong&gt;. It supports both the Pages Router and the App Router, but its design centers on the App Router: translations work in async Server Components, Client Components, Server Actions, metadata, and route handlers, and it ships its own locale-aware routing, middleware, and navigation helpers. Messages use &lt;strong&gt;ICU MessageFormat&lt;/strong&gt; out of the box, so plurals, selects, and number and date formatting live inside the translation string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;next-intl&lt;/th&gt;
&lt;th&gt;react-i18next&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework scope&lt;/td&gt;
&lt;td&gt;Next.js only&lt;/td&gt;
&lt;td&gt;Any React renderer (web, React Native, Electron)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App Router &amp;amp; Server Components&lt;/td&gt;
&lt;td&gt;First-class: &lt;code&gt;getTranslations&lt;/code&gt; and &lt;code&gt;useTranslations&lt;/code&gt; in server code&lt;/td&gt;
&lt;td&gt;Works, but hooks are client-only; server code needs a per-request i18next instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pages Router&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;td&gt;Supported (commonly via next-i18next)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message syntax&lt;/td&gt;
&lt;td&gt;ICU MessageFormat, built in&lt;/td&gt;
&lt;td&gt;i18next JSON format; ICU via the i18next-icu plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plurals&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{count, plural, one {…} other {…}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Suffixed keys: &lt;code&gt;key_one&lt;/code&gt;, &lt;code&gt;key_other&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routing &amp;amp; middleware&lt;/td&gt;
&lt;td&gt;Built in: &lt;code&gt;defineRouting&lt;/code&gt;, middleware, &lt;code&gt;createNavigation&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not included; you write your own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type safety&lt;/td&gt;
&lt;td&gt;Module augmentation of &lt;code&gt;AppConfig&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Module augmentation of &lt;code&gt;CustomTypeOptions&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Namespaces &amp;amp; lazy loading&lt;/td&gt;
&lt;td&gt;Nested message objects; load what you need in &lt;code&gt;request.ts&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Namespaces are a core concept; lazy loading via backends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language detection&lt;/td&gt;
&lt;td&gt;Middleware: URL prefix, cookie, &lt;code&gt;Accept-Language&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;i18next-browser-languagedetector or custom detector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin ecosystem&lt;/td&gt;
&lt;td&gt;Small and focused&lt;/td&gt;
&lt;td&gt;Extensive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning curve&lt;/td&gt;
&lt;td&gt;Gentle for Next.js developers&lt;/td&gt;
&lt;td&gt;Moderate: more configuration options and concepts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Setting Up next-intl in the App Router
&lt;/h2&gt;

&lt;p&gt;A current next-intl (v4) App Router setup is split into a few small files. First, a single routing definition that the middleware and navigation helpers share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/i18n/routing.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineRouting&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl/routing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineRouting&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;defaultLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middleware negotiates the locale and redirects or rewrites requests to the right prefix. (In Next.js 16 the &lt;code&gt;middleware.ts&lt;/code&gt; convention was renamed to &lt;code&gt;proxy.ts&lt;/code&gt;; the content is the same.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/proxy.ts  (src/middleware.ts on Next.js 15 and earlier)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;createMiddleware&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl/middleware&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./i18n/routing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;createMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/((?!api|_next|_vercel|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;..*).*)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request configuration tells next-intl which messages to load for the current request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/i18n/request.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getRequestConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hasLocale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./routing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;getRequestConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;requestLocale&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;requestLocale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hasLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultLocale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`../../messages/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the &lt;code&gt;[locale]&lt;/code&gt; layout wraps the tree in &lt;code&gt;NextIntlClientProvider&lt;/code&gt; so Client Components can read messages, and calls &lt;code&gt;setRequestLocale&lt;/code&gt; so pages can be statically rendered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/[locale]/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextIntlClientProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hasLocale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;setRequestLocale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/i18n/routing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LocaleLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;hasLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;setRequestLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NextIntlClientProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;NextIntlClientProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using translations looks the same everywhere. In an async Server Component you &lt;code&gt;await getTranslations()&lt;/code&gt;; in a regular component — server or client — you call &lt;code&gt;useTranslations()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server Component&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getTranslations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Pricing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getTranslations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pricing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Client Component&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useTranslations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SeatCounter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTranslations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pricing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;seats&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, &lt;code&gt;createNavigation(routing)&lt;/code&gt; gives you locale-aware &lt;code&gt;Link&lt;/code&gt;, &lt;code&gt;redirect&lt;/code&gt;, &lt;code&gt;usePathname&lt;/code&gt;, and &lt;code&gt;useRouter&lt;/code&gt; wrappers, so you never hand-build &lt;code&gt;/fr/pricing&lt;/code&gt; URLs. For the full walkthrough from an empty project, see our &lt;a href="https://www.babelize.co/blogs/implement-i18n-nextjs-next-intl/" rel="noopener noreferrer"&gt;step-by-step guide to i18n in Next.js with next-intl&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up react-i18next in the App Router
&lt;/h2&gt;

&lt;p&gt;react-i18next was designed before Server Components existed, and its hooks rely on React context, which only exists in Client Components. In the App Router that leaves two layers to configure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server side:&lt;/strong&gt; the common pattern is to create a fresh i18next instance per request, so concurrent requests in different languages never share state. &lt;code&gt;i18next-resources-to-backend&lt;/code&gt; lets you lazy-load JSON files per language and namespace with dynamic imports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/i18n/server.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createInstance&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;i18next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;resourcesToBackend&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;i18next-resources-to-backend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;initReactI18next&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-i18next/initReactI18next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i18n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createInstance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;i18n&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initReactI18next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resourcesToBackend&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`../locales/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;defaultNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fallbackLng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFixedT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In a Server Component&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pricing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Client side:&lt;/strong&gt; a separate, long-lived i18next instance initialized in a client module, with &lt;code&gt;useTranslation()&lt;/code&gt; in Client Components. You keep the client instance's language in sync with the locale segment of the URL, often by passing it down from the layout.&lt;/p&gt;

&lt;p&gt;You also write locale routing yourself: a middleware that reads the cookie or &lt;code&gt;Accept-Language&lt;/code&gt; header, redirects to a locale prefix, and a &lt;code&gt;[lng]&lt;/code&gt; segment in your app directory. None of this is hard, but it is code you own and maintain. (If you are on the &lt;strong&gt;Pages Router&lt;/strong&gt;, &lt;code&gt;next-i18next&lt;/code&gt; wraps most of this for you; it does not target the App Router.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Message Syntax: ICU vs i18next JSON
&lt;/h2&gt;

&lt;p&gt;This is the difference that matters most to translators, and it is easy to overlook. next-intl uses ICU MessageFormat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Pricing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"seats"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{count, plural, =0 {No seats} one {# seat} other {# seats}}"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;i18next uses its own JSON format, where interpolation uses double braces and plurals are separate keys with CLDR category suffixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"seats_zero"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No seats"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"seats_one"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{count}} seat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"seats_other"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{count}} seats"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both handle languages with complex plural rules correctly, because both resolve the category with &lt;code&gt;Intl.PluralRules&lt;/code&gt;. The i18next format is easier to read for simple strings; ICU keeps the whole sentence in one key, which translators and translation platforms generally handle better for gender and nested selects. If you want ICU with i18next, add the &lt;code&gt;i18next-icu&lt;/code&gt; plugin — but then you are using the ICU format, not the i18next one, so pick one per project. Our &lt;a href="https://www.babelize.co/blogs/icu-messageformat-plurals-guide/" rel="noopener noreferrer"&gt;ICU MessageFormat guide&lt;/a&gt; covers plural and select syntax in depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type Safety
&lt;/h2&gt;

&lt;p&gt;Both libraries can type-check your translation keys, and both do it the same way: TypeScript module augmentation pointing at your source-language JSON file. Neither generates code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next-intl: global.d.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/i18n/routing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./messages/en.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-intl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AppConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nl"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// react-i18next: i18next.d.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;common&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./locales/en/common.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;i18next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CustomTypeOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;defaultNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;common&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;common&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With either setup, a typo like &lt;code&gt;t("pricng.title")&lt;/code&gt; becomes a compile error. next-intl can additionally type ICU arguments in recent versions, so a missing &lt;code&gt;count&lt;/code&gt; is caught as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundle Size and Performance
&lt;/h2&gt;

&lt;p&gt;Avoid picking either library based on a headline bundle number; what dominates in practice is &lt;em&gt;how many messages you ship to the browser&lt;/em&gt;, not the size of the runtime.&lt;/p&gt;

&lt;p&gt;With next-intl, text rendered in Server Components is resolved on the server and arrives as HTML. The message catalog itself does not need to be sent at all for those components. For Client Components, &lt;code&gt;NextIntlClientProvider&lt;/code&gt; passes messages down; on large apps you can pass only the namespaces a subtree needs (for example with &lt;code&gt;pick(messages, ["Pricing"])&lt;/code&gt;) instead of the entire file.&lt;/p&gt;

&lt;p&gt;With react-i18next, server-rendered text works the same way when you use a per-request instance. Client Components need the resources loaded into the client instance, and the ecosystem's answer is namespaces plus a backend (HTTP or dynamic import) that loads &lt;code&gt;pricing.json&lt;/code&gt; only when a component asks for the &lt;code&gt;pricing&lt;/code&gt; namespace.&lt;/p&gt;

&lt;p&gt;Both approaches can produce lean pages. next-intl makes the lean path the default in the App Router; react-i18next makes it a configuration choice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8tgy1xaf673xse5rs8n4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8tgy1xaf673xse5rs8n4.webp" alt="Diagram comparing where translation runs in the Next.js App Router. next-intl: createMiddleware handles locale detection, i18n/request.ts loads messages/${locale}.json per request, Server Components call getTranslations and render text to HTML, and NextIntlClientProvider passes picked messages to Client Components using useTranslations. react-i18next: your own middleware and (lng) segment, createInstance per request with resourcesToBackend loading locales/${lng}/${ns}.json and getT on the server, and a separate long-lived client instance using useTranslation with a backend that loads namespaces on demand." width="800" height="528"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Where translation runs: next-intl resolves text per request on the server; react-i18next uses a per-request server instance plus a client instance.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which One Should You Choose?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose next-intl if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app is Next.js, and you have no plans to share translations with a non-Next.js client&lt;/li&gt;
&lt;li&gt;You use the App Router and want translations in Server Components without extra glue&lt;/li&gt;
&lt;li&gt;You want locale routing, middleware, and localized navigation handled for you&lt;/li&gt;
&lt;li&gt;You prefer ICU MessageFormat, or your translation vendor expects it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose react-i18next if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You ship React Native, Electron, Vite, or Remix apps alongside (or instead of) Next.js&lt;/li&gt;
&lt;li&gt;You want one translation setup shared by the frontend and a Node.js backend&lt;/li&gt;
&lt;li&gt;Your team already uses i18next and its file format, detectors, or backends&lt;/li&gt;
&lt;li&gt;You need a specific plugin, such as a CMS or translation-platform backend that loads strings at runtime&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful tiebreaker: &lt;strong&gt;whoever owns the most code that renders text wins.&lt;/strong&gt; If 90% of your UI lives in a Next.js app, use next-intl there even if a small React Native app uses i18next — sharing raw JSON between two formats is easier than fighting the wrong library in your main app.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexphag79ya1eea1f60z2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexphag79ya1eea1f60z2.webp" alt="Decision flowchart. Q1: Is the app a Next.js App Router project? No leads to react-i18next. Yes leads to Q2: do the same translations also need to run in React Native, Electron, Vite, Remix or Node.js? Yes leads to react-i18next. No leads to Q3: does your team already rely on i18next, its file format, detectors, backends or a plugin? Yes leads to react-i18next, No leads to next-intl. Tiebreaker: whoever owns the most code that renders text wins." width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Decision guide: next-intl for Next.js App Router apps, react-i18next for cross-platform setups or existing i18next teams.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating from react-i18next to next-intl
&lt;/h2&gt;

&lt;p&gt;Teams moving an existing Next.js app to the App Router often make this switch. The work splits into three parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Convert the message files.&lt;/strong&gt; Merge namespaces into nested objects (or keep one file per namespace and merge them in &lt;code&gt;request.ts&lt;/code&gt;), turn &lt;code&gt;{{name}}&lt;/code&gt; into &lt;code&gt;{name}&lt;/code&gt;, and collapse &lt;code&gt;key_one&lt;/code&gt;/&lt;code&gt;key_other&lt;/code&gt; pairs into a single ICU plural. Script this; do not do it by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace the calls.&lt;/strong&gt; &lt;code&gt;useTranslation("pricing")&lt;/code&gt; becomes &lt;code&gt;useTranslations("Pricing")&lt;/code&gt;, and &lt;code&gt;&amp;lt;Trans&amp;gt;&lt;/code&gt; becomes &lt;code&gt;t.rich()&lt;/code&gt; with tag callbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete your routing glue.&lt;/strong&gt; Custom locale middleware and link helpers get replaced by &lt;code&gt;defineRouting&lt;/code&gt;, &lt;code&gt;createMiddleware&lt;/code&gt;, and &lt;code&gt;createNavigation&lt;/code&gt;. Keep your existing URL structure so you do not lose rankings; see our &lt;a href="https://www.babelize.co/blogs/hreflang-multilingual-seo-guide/" rel="noopener noreferrer"&gt;hreflang and multilingual SEO guide&lt;/a&gt; before changing locale prefixes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Migrate route by route behind a feature branch, and run a pseudo-locale through the converted pages to catch any string that slipped through untranslated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Calling &lt;code&gt;useTranslation()&lt;/code&gt; in a Server Component.&lt;/strong&gt; With react-i18next, hooks need a Client Component. Use a per-request instance on the server instead of adding &lt;code&gt;"use client"&lt;/code&gt; everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharing one global i18next instance on the server.&lt;/strong&gt; Changing its language for one request can leak into another concurrent request. Create an instance per request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;setRequestLocale&lt;/code&gt; with next-intl.&lt;/strong&gt; Without it, pages that use translations opt into dynamic rendering, and you lose static generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passing the whole catalog to the client.&lt;/strong&gt; Wrapping the root in a provider with every message for every feature is convenient, but it ships text the page never shows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing message formats.&lt;/strong&gt; ICU strings in an i18next project without the ICU plugin render literally, braces and all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inconsistent locale codes.&lt;/strong&gt; &lt;code&gt;en_US&lt;/code&gt; in file names, &lt;code&gt;en-US&lt;/code&gt; in the URL, &lt;code&gt;en&lt;/code&gt; in the HTML lang attribute. Pick BCP 47 tags once; our &lt;a href="https://www.babelize.co/blogs/locale-codes-bcp-47-explained/" rel="noopener noreferrer"&gt;locale codes guide&lt;/a&gt; explains the rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand-formatting numbers and dates.&lt;/strong&gt; Both libraries expose locale-aware formatting; pass raw values, never &lt;code&gt;toFixed(2)&lt;/code&gt; strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the difference between next-intl and react-i18next?
&lt;/h3&gt;

&lt;p&gt;next-intl is built specifically for Next.js, with first-class App Router and Server Component support, built-in locale routing, and ICU MessageFormat. react-i18next is the React binding for i18next, a framework-agnostic library that also runs in React Native, Vite, Remix, and Node.js, with a large plugin ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which library works best with the Next.js App Router?
&lt;/h3&gt;

&lt;p&gt;next-intl. Its getTranslations and useTranslations APIs work in Server Components, and it provides routing, middleware, and navigation helpers. react-i18next works in the App Router too, but its hooks are client-only, so server rendering requires creating an i18next instance per request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use react-i18next with React Native?
&lt;/h3&gt;

&lt;p&gt;Yes. react-i18next supports React Native, which makes it a strong choice for projects that share translations between web and mobile. next-intl only targets Next.js, so a React Native app would need a different library.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does next-intl support ICU MessageFormat?
&lt;/h3&gt;

&lt;p&gt;Yes. next-intl uses ICU MessageFormat natively for plurals, selects, and number and date formatting. i18next uses its own JSON format with suffixed plural keys such as key_one and key_other, and supports ICU only through the i18next-icu plugin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is next-intl or react-i18next faster?
&lt;/h3&gt;

&lt;p&gt;Neither runtime is a meaningful bottleneck. What matters is how many messages reach the browser. next-intl renders Server Component text on the server by default, while react-i18next achieves similar results with per-request server instances and lazily loaded namespaces on the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Short Version
&lt;/h2&gt;

&lt;p&gt;next-intl is the specialist: if your product is a Next.js App Router app, it gives you Server Component translations, routing, and ICU with the least code. react-i18next is the generalist: if your translations need to work across React Native, other frameworks, or a backend, its framework-agnostic core and ecosystem are hard to beat. Pick based on where most of your text renders, keep your message files in one format, and type your keys either way. If you are still deciding how to structure those files, start with our explanation of &lt;a href="https://www.babelize.co/blogs/json-translation-files-explained/" rel="noopener noreferrer"&gt;JSON translation files&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Whichever library you choose, the translation files still have to stay in sync as your source strings change. &lt;a href="https://www.babelize.co/" rel="noopener noreferrer"&gt;Babelize&lt;/a&gt; detects new and changed keys and generates translations for every locale in your repository, with guides for both the &lt;a href="https://docs.babelize.co/sdk/nextjs" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt; and &lt;a href="https://docs.babelize.co/sdk/react" rel="noopener noreferrer"&gt;React&lt;/a&gt; SDKs.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed by the Babelize team.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.babelize.co/blogs/next-intl-vs-react-i18next/" rel="noopener noreferrer"&gt;Babelize blog&lt;/a&gt;. Disclosure: Babelize is our product, an AI localization tool for Next.js, React and Vue apps: it generates and syncs your JSON locale files from your repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>i18n</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
