# How We Turn a Next.js App's Hardcoded Strings Into a Reviewed i18n Pull Request

> An AST pass that finds the text worth translating, keys that stay stable, a lockfile that makes reruns nearly free, LLM output we refuse to trust, and a code rewrite that keeps your formatting.
- **Author**: Mohit Sapat
- **Published**: 2026-10-24
- **URL**: https://www.babelize.co/blogs/how-babelize-localizes-nextjs-app

---

<p class="lead">
    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.
</p>

<h2>The Problem: Text Lives Inside Components</h2>

<p>A typical Next.js page looks like this:</p>

<pre><code>// src/app/dashboard/page.tsx
export default function Dashboard({ user, count }) {
  return (
    &lt;main&gt;
      &lt;h1&gt;Welcome back, {user.name}&lt;/h1&gt;
      &lt;p&gt;Read the &lt;Link href="/terms"&gt;terms&lt;/Link&gt; before you continue.&lt;/p&gt;
      &lt;input placeholder="Search projects" className="input" /&gt;
      &lt;span&gt;{count} projects&lt;/span&gt;
    &lt;/main&gt;
  );
}</code></pre>

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

<h2>Step 1: Find the Strings with an AST, Not a Regex</h2>

<p>
    Every file is parsed with <code>@babel/parser</code> (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, <code>+</code> concatenations and both branches of ternaries.
</p>

<p>Most of the work is deciding what <em>not</em> to extract:</p>

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

<h3>Sentences, not fragments</h3>

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

<pre><code>"Welcome back, {name}"
"Read the &lt;link&gt;terms&lt;/link&gt; before you continue."
"{count, plural, one {# project} other {# projects}}"</code></pre>

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

<p>
    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.
</p>

<h2>Step 2: Keys That Stay Put</h2>

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

<p>
    Collisions get <code>_2</code>, <code>_3</code> 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.
</p>

<h2>Step 3: A Lockfile, So Reruns Are Nearly Free</h2>

<p>
    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:
</p>

<pre><code>.babelize/local/babelize-lock.json</code></pre>

<p>On the next run, keys fall into four groups:</p>

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

<p>
    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.
</p>

<h2>Step 4: Translate, Then Don't Trust the Output</h2>

<p>
    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 <code>[#VAR_0#]</code>, and the prompt says each must come back exactly once. ICU messages keep their argument names, <code>#</code>, and the target language's own plural categories.
</p>

<p>Then every value is checked before it's written:</p>

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

<p>
    A rejected key gets one targeted repair attempt, with a note telling the model what was wrong. If that fails too, the key is <strong>left out of the locale file</strong>. 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.
</p>

<h2>Step 5: Rewrite the Code Without Wrecking Your Diff</h2>

<p>
    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:
</p>

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

<pre><code>const t = useTranslations();

&lt;h1&gt;{t("dashboard.welcomeBack", { name: user.name })}&lt;/h1&gt;
&lt;p&gt;
  {t.rich("dashboard.readTheTermsBeforeYou", {
    link: (chunks) =&gt; &lt;Link href="/terms"&gt;{chunks}&lt;/Link&gt;,
  })}
&lt;/p&gt;</code></pre>

<p>Then the project is wired up:</p>

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

<p>
    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.
</p>

<h2>What It Refuses to Guess</h2>

<p>The interesting part of a tool like this is where it stops. Babelize reports these instead of improvising:</p>

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

<h2>Local Diff or Pull Request</h2>

<p>There are two ways to run the same pipeline:</p>

<pre><code>npx babelize</code></pre>

<p>
    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 <code>git diff</code> and commit, lockfile included. With your own LLM key in the environment you don't need an account.
</p>

<p>
    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.
</p>

<p>
    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".
</p>
---
- [All articles](https://www.babelize.co/blogs)