How to migrate a hardcoded codebase to i18n — automatically

Wrapping hardcoded strings in translation calls is weeks of manual work. Polyglot's wrap command is an i18n codemod that does it with AST-located, byte-exact rewrites — and refuses to touch anything it can't prove safe.

14 min read← All articles

Every i18n migration starts the same way. Someone signs an enterprise deal that requires German, or your analytics show 40% of traffic coming from Brazil, and suddenly a codebase with 800 hardcoded strings needs to become a codebase with zero.

The translation part is a solved problem. The part that kills timelines is what comes before it: finding every user-facing string and wrapping it in a translation call. <h1>Welcome back</h1> has to become <h1>{t('welcome_back')}</h1>, a catalog entry has to exist for welcome_back, the t function has to be imported and bound, and the i18n provider has to be mounted — in every component, in the right order, without breaking anything.

This post is about automating that step. Polyglot's wrap command is an i18n codemod: it rewrites hardcoded strings into translation calls across seven frameworks, generates the catalogs, injects the imports and providers, and, most importantly, checks each supported rewrite before it writes a byte. Anything it can't handle safely stays as manual work, with a reason.

What the manual migration actually costs

Before the automation pitch, the honest baseline. Wrapping strings by hand means, for every component in your codebase:

  1. Read the component and decide which strings are user-facing (not CSS classes, not API routes, not test IDs)
  2. Invent a key for each string and add it to the catalog
  3. Replace the string with a translation call
  4. Import the hook or function and bind it in the component
  5. Verify the component still renders

We've broken down the full cost of localization before, but the extraction line item alone looks like this:

App sizeEstimated stringsDeveloper timeCost at $75/hr
Small (landing page)50-2004-8 hours$300-600
Medium (SaaS app)500-2,0002-5 days$1,200-3,000
Large (complex platform)5,000+2-4 weeks$6,000-12,000

It's also uniquely error-prone work. It's repetitive enough that attention drifts, but each edit is a real code change that can break a build: a missed brace, a string wrapped inside a metadata export where hooks don't exist, a key collision between two components. Teams that do this migration by hand often need a second pass just for the strings the first pass missed.

Traditional translation management systems mostly start later. Crowdin, Lokalise, and Phrase work from translation files you've already extracted. If your strings are still hardcoded in JSX, a TMS has little to manage yet. The extraction gap is yours.

The codemod approach

Polyglot treats the migration as a compiler problem, not a search-and-replace problem. The workflow is two commands:

polyglot scan   # list hardcoded user-facing string candidates (free, no account)
polyglot wrap   # rewrite the ones it can handle safely into translation calls

scan uses tree-sitter AST parsing to find likely user-facing strings. Its results are candidates to review, not a guarantee of full UI coverage. It knows the difference between JSX text content and an import path because it's reading the syntax tree, not matching quotes with a regex.

wrap takes those detections and rewrites the code. Here's what it does to a Next.js component using next-intl. This is real output from CLI 0.14.4, not a mockup:

Before:

"use client";

export default function Page() {
    return (
        <main>
            <h1>Welcome back</h1>
            <p>Your projects are ready</p>
            <input placeholder="Search projects" />
        </main>
    );
}

After polyglot wrap:

"use client";
import { useTranslations } from "next-intl";

export default function Page() {
  const t = useTranslations("src.app.page");
    return (
        <main>
            <h1>{t('welcome_back')}</h1>
            <p>{t('your_projects_are_ready')}</p>
            <input placeholder={t('search_projects')} />
        </main>
    );
}

Note what happened beyond the string replacement: the useTranslations import was added, the hook was bound with a namespace derived from the file path, and the placeholder attribute was converted from a string literal to a JSX expression. The source catalog was written too:

{
  "src": {
    "app": {
      "page": {
        "search_projects": "Search projects",
        "welcome_back": "Welcome back",
        "your_projects_are_ready": "Your projects are ready"
      }
    }
  }
}

And the run ends with a summary you can put in a commit message:

✓ 3 candidates auto-wrapped · 0 candidates in 0 groups require manual review · 0 unsafe transforms  →  polyglot-i18n-report.md

It scaffolds the setup, not just the call sites

A migration isn't done when the strings are wrapped; the runtime has to be wired. If your project has no i18n setup at all, wrap scaffolds it. In a Vue project with zero vue-i18n configuration, one wrap run produces the component rewrite:

<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>

<template>
  <main>
    <h1>{{ t('src.App.welcome_back') }}</h1>
    <p>{{ t('src.App.your_projects_are_ready') }}</p>
  </main>
</template>

...plus a new src/i18n/index.ts that creates the i18n instance, plus this edit to src/main.ts to mount it:

import { createApp } from 'vue';
import App from './App.vue';
import i18n from './i18n';

createApp(App).use(i18n).mount('#app');

Angular gets the same treatment, including the detail that trips up most hand migrations: standalone components. With ngx-translate, wrap rewrites the template to the translate pipe (including attribute bindings like aria-label) and injects TranslatePipe into the component's standalone imports: array so the pipe actually resolves:

<h1>{{ 'src.app.app.component.welcome_back' | translate }}</h1>
<button [attr.aria-label]="'src.app.app.component.refresh_the_list' | translate">
  {{ 'src.app.app.component.reload' | translate }}</button>
import { Component } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';

@Component({
  selector: 'app-root',
  imports: [TranslatePipe, ],
  templateUrl: './app.component.html',
})
export class AppComponent {}

This works across the seven frameworks Polyglot detects — Next.js, Astro, SvelteKit, React Native, Flutter, Vue, and Angular — and the i18n libraries teams actually use: next-intl, i18next/next-i18next, react-intl, Lingui, vue-i18n, svelte-i18n, @angular/localize, ngx-translate, Transloco, Flutter's AppLocalizations, and Paraglide. If your project already uses one of these, wrap detects it from your dependencies and emits that library's call shape; if you have nothing installed, it defaults sensibly and tells you what it chose. How deep the support goes varies by framework, library, and setup. Next.js App Router with next-intl is the best-tested path, so review the patch and run your app either way.

Keys you can live with for years

Key naming sounds like a bikeshed until you're maintaining 2,000 of them. wrap generates deterministic, readable keys: a namespace derived from the file path plus a slug derived from the text. <h1>Welcome back</h1> in src/app/page.tsx becomes src.app.page.welcome_back. You can go from a key in a catalog to the component that uses it without grepping, and two components with the same text get distinct, non-colliding keys.

Re-running wrap is safe, too: already-wrapped strings are translation calls now, so the scanner doesn't detect them again, and keys from previous runs are preserved in the catalog. Wrapping isn't a one-shot migration event — it's a command you keep running as the codebase grows.

Strings that live in data, not markup

A chunk of every real codebase's user-facing text doesn't sit in JSX at all. It sits in data:

const PLANS = [
  { name: "Starter", description: "For side projects" },
  { name: "Growth", description: "For small teams" },
];

Naively wrapping those values breaks the code: you can't call a hook in module scope. wrap has a dedicated data-object pass for this: it identifies translatable values in arrays and objects, runs an escape analysis on how the data flows into rendering before deciding anything, rewrites the values into catalog keys, and updates the call sites that render them. When the flow can't be proven safe (the array is passed through a function, accessed dynamically, spread into props, or read somewhere other than a rendered translation call), those exact strings are flagged in the report (function-indirection, dynamic-access, spread, unproven-consumer) instead of being guessed at. We wrote up the general problem in translating React data arrays; wrap automates the mechanical part of that pattern.

Why you can trust a tool that edits your code

An i18n codemod is only useful if you'd let it loose on a production codebase. Search-and-replace scripts fail that bar immediately. Here is the engineering that makes wrap different, in the order the safety layers fire.

1. Dry-run first, always

polyglot wrap --dry-run runs the entire pipeline (detection, key generation, transformation, the parse guard described below) and touches nothing. You see every planned rewrite and every string it would decline to touch, with the reason. The transparency report is written on dry runs too, so you can review the full plan as a file before a single source line changes. (And before a real run, wrap checks git status and warns you if the working tree is dirty, so there's always a clean commit to roll back to. Each run also records its writes, so polyglot undo <run-id> can restore them.)

For a reviewable, repeatable change, save a plan instead: polyglot wrap --plan records the exact patch, polyglot wrap --show <plan-id> lets you inspect it, and polyglot wrap --apply <plan-id> applies exactly those bytes — or refuses if the inputs changed.

2. AST-located, byte-exact replacement

Every replacement is located through the AST, not by text search. wrap resolves each detected string to an exact byte range in the file and splices the replacement into that range. It never "finds the string somewhere in the file" — if the same text appears twice, each occurrence is its own node with its own byte range. There is no regex in the write path.

3. A differential re-parse guard that fails closed

After transforming a file, and before writing it, wrap re-parses the transformed content with the same tree-sitter grammar used for detection and compares parse errors against the original. If the transform would introduce even one new syntax error, the file is rejected: the write is abandoned, the file rolls back to its snapshot, and the affected strings are flagged in the report as internal-guard instead of being applied.

The comparison is differential on purpose. Real codebases contain files that already have constructs the parser dislikes; the guard doesn't demand a perfectly clean parse, it demands that wrap made things no worse. And the failure mode is closed: when in doubt, the file is left exactly as it was.

4. Atomic writes with rollback

Writes go through an atomic write path (write to a temporary file, then rename), so a crash mid-run can never leave a half-written source file. This applies even to file types the parse guard doesn't cover — defense in depth, not a single gate.

5. Everything it won't touch is reported, with a reason

wrap does not silently skip anything. Every string it declines to rewrite lands in polyglot-i18n-report.md with a category and a human-readable reason. The categories are specific enough to act on:

  • could-not-apply — the byte-exact replacement couldn't be made safely
  • rich-text-unsupported — mixed formatting runs in frameworks where rich-text codegen isn't supported yet (Vue and Svelte rich text is flagged, not transformed; React-family rich text gets real codegen)
  • server-component-client-hook — wrapping would inject a client-only hook into a React Server Component
  • shadowed-translator / translator-not-in-scope — a local variable would shadow t, or there's no component body to bind the hook in
  • dynamic-access, function-indirection, spread, unresolved-prop-type, unproven-consumer — data flows the analyzer can't prove safe
  • existing-catalog-unjoined — your existing i18n setup was detected but its catalog source couldn't be resolved, so wrap flags instead of creating a parallel setup

That last behavior is a design rule worth calling out: when wrap finds an existing i18n installation it can't safely merge into (say, i18next initialized with inline resources), it flags the strings with a resolution recipe rather than writing a second, conflicting catalog next to yours. A migration tool that guesses in that situation creates the exact mess it was supposed to prevent.

6. Post-wrap wiring verification

After applying, wrap verifies the result actually works as an i18n setup: injected imports resolve, the runtime package is installed, the provider is mounted, catalog files exist and contain the new keys. In CI you can make this blocking with --strict-wiring, which exits non-zero if any wiring warning exists — the wrap still completes and the report is still written, so the failure is diagnosable from the artifact.

There's also --file for scoping a run to a single file when you want to migrate incrementally, one route or one feature at a time.

Already halfway migrated? It merges, it doesn't replace

Most real codebases aren't greenfield. You adopted i18next two years ago, translated 60% of the app, and the remaining 40% is hardcoded strings nobody got to. The worst thing a tool could do is ignore your existing catalogs and start a parallel scheme.

wrap detects existing setups and behaves as a guest in them:

  • i18next / next-i18next: if your repo keeps public/locales/<lng>/<ns>.json catalogs, new keys are merged into your namespace files, call-site keys stay relative to each component's bound namespace, and no duplicate setup is scaffolded. The merge is textual — your key order and formatting are preserved, new keys are spliced in.
  • vue-i18n: wrap introspects where your app actually loads catalogs (directory, format, default locale) and writes keys there, not to an orphaned messages/en.json.
  • svelte-i18n: existing register()'d catalogs are merged into when they're statically resolvable. When they aren't, strings are flagged rather than parallel-written. In a SvelteKit file that already uses svelte-i18n, the wrap output is surgical — existing translated calls like {$_('nav.home')} are left alone and only the hardcoded <h1>Welcome back</h1> gets wrapped.

Key order preservation sounds like a small thing until you review the PR: a merge that preserves order produces a diff with three added lines; a merge that re-serializes the catalog produces a 400-line diff nobody can review.

Staying migrated

The second-worst outcome of an i18n migration is doing it twice. Every feature shipped after the migration introduces new hardcoded strings, and without enforcement they accumulate until someone schedules "i18n cleanup" again.

Because detection and wrapping are CLI commands, the enforcement is boring plumbing:

  • polyglot hook install adds a git pre-commit hook that runs polyglot scan, so new hardcoded strings show up before they're committed
  • polyglot coverage reports translation coverage as a table, JSON, or a badge, with --by-file when you want to know where the gaps are
  • The open Polyglot GitHub Action compares each PR against its base and reports new, existing, and resolved findings — it works in guest mode without an API key
  • polyglot wrap --strict-wiring in CI fails the job if a wrap ever leaves imports, providers, or catalogs in a broken state

One more thing worth saying because it's the question skeptical engineers ask: the output is yours. wrap writes standard catalogs for your i18n library — next-intl messages, i18next namespaces, ARB files for Flutter — and standard translation calls in your code. If you stop using Polyglot tomorrow, nothing breaks and nothing needs to be unwound. We've written about what happens if you stop using Polyglot precisely because a migration tool that creates lock-in is just relocating the problem.

The full migration, end to end

wrap is the second verb in a four-verb workflow:

curl -fsSL https://getpolyglot.ai/install.sh | bash

polyglot scan        # find hardcoded string candidates — free, no account
polyglot wrap        # rewrite them into translation calls — also free
polyglot translate   # fill the catalogs in your target languages, then review
polyglot preview     # see your app running in German before you deploy

Scan, wrap, preview, and validate run entirely locally and are free without limits — no account required. Translation is the metered part: 50 strings with no account, 500 source strings free once you sign up (a lifetime allowance, not a monthly drip; detection stays free either way), then paid plans from $49/month, or $39/month billed annually. Generated translations wait for review in the dashboard; automatic approval is off.

On quality: in our published benchmark (June 2026 pipeline, AI-judged), translations scored 4.96/5 across 49 languages — 100 UI strings × 49 languages, or 4,900 translations — with 100% of interpolation placeholders preserved. That run measured the June pipeline; production moved to a different model on September 14, 2026, so read it as a historical result, not a score for today's model. Placeholders matter more than usual here, because wrap is the thing generating them in the first place.

The honest summary: an i18n migration has two hard parts, and a TMS mostly starts after both. Detection is a parsing problem and wrapping is a codemod problem. Solve as much as you can mechanically — with a dry run you can read, a parse guard that fails closed, and a report of everything left for a human — and most of the "2-4 weeks" line turns into reviewing a diff and finishing the items the report hands back.

Start in your terminal

Stop hunting for untranslated strings.

Install the CLI, run a scan, and see exactly what you're missing. Free, no account required.

$curl -fsSL https://getpolyglot.ai/install.sh | bash
How to migrate a hardcoded codebase to i18n — automatically - Polyglot Blog | Polyglot