All posts

Stop Translating Strings by Hand: Automate Your i18n Workflow

A step-by-step tutorial for automating translations with react-i18next, i18next-parser, and a translation API. Extract, translate, and commit — no manual work.

You've set up react-i18next. You have English JSON files. Every time you add a new string, you manually copy it to fr.json, de.json, ja.json, open Google Translate in a browser tab, paste, copy, paste back. Maybe you have a spreadsheet somewhere.

This doesn't have to be your life. Here's a 30-minute setup that extracts new strings, translates them via API, and commits the results — no manual translation work required.

The stack

  • react-i18next — your i18n framework (already in place)
  • i18next-parser — extracts translation keys from source code
  • A translation API — to translate extracted strings (we'll use auto18n, but any API works)
  • A Node script — ties it together
  • CI — runs the script on every PR or merge

Step 1: Extract translation keys automatically

i18next-parser scans your source code and pulls out every t('key') call into JSON files.

npm install -D i18next-parser

Create i18next-parser.config.js:

module.exports = {
  locales: ["en", "fr", "de", "ja", "es"],
  defaultLocale: "en",
  output: "public/locales/$LOCALE/$NAMESPACE.json",
  input: ["src/*/.{ts,tsx,js,jsx}"],
  sort: true,
  createOldCatalogs: false,
  keySeparator: ".",
  namespaceSeparator: ":",
  defaultValue: (locale, namespace, key) => {
    // For the default locale, use the key as the value
    // This means t('Save changes') uses "Save changes" as both key and English text
    return locale === "en" ? key : "";
  },
};

Run it:

npx i18next-parser

This generates/updates JSON files for each locale. New keys get added with empty values for non-English locales. Existing translations are preserved.

After running, your file structure looks like:

public/locales/
  en/
    translation.json    # {"Save changes": "Save changes", "Cancel": "Cancel", ...}
  fr/
    translation.json    # {"Save changes": "", "Cancel": "", ...}
  de/
    translation.json    # {"Save changes": "", "Cancel": "", ...}

Step 2: Find untranslated strings

Write a script that identifies empty values — these are the strings that need translation:

// scripts/find-untranslated.js
const fs = require("fs");
const path = require("path");

const LOCALES_DIR = path.join(__dirname, "..", "public", "locales"); const DEFAULT_LOCALE = "en";

function findUntranslated() { const locales = fs .readdirSync(LOCALES_DIR) .filter((f) => f !== DEFAULT_LOCALE);

const results = {};

for (const locale of locales) { const filePath = path.join(LOCALES_DIR, locale, "translation.json"); if (!fs.existsSync(filePath)) continue;

const translations = JSON.parse(fs.readFileSync(filePath, "utf-8")); const untranslated = Object.entries(translations) .filter(([key, value]) => !value || value === "") .map(([key]) => key);

if (untranslated.length > 0) { results[locale] = untranslated; } }

return results; }

const untranslated = findUntranslated(); console.log(JSON.stringify(untranslated, null, 2));

Step 3: Translate via API

Now the key part — automatically translate the missing strings:

// scripts/translate-missing.js
const fs = require("fs");
const path = require("path");

const LOCALES_DIR = path.join(__dirname, "..", "public", "locales"); const DEFAULT_LOCALE = "en"; const API_URL = "https://api.auto18n.com/v1/translate"; const API_KEY = process.env.AUTO18N_API_KEY;

async function translateBatch(texts, targetLocale) { const response = await fetch(API_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: Bearer ${API_KEY}, }, body: JSON.stringify({ texts, sourceLocale: "en", targetLocale, context: "UI strings for a web application", }), });

if (!response.ok) { throw new Error(Translation API error: ${response.status}); }

const data = await response.json(); return data.translations; }

async function translateMissing() { // Load English strings as source const enPath = path.join(LOCALES_DIR, DEFAULT_LOCALE, "translation.json"); const enStrings = JSON.parse(fs.readFileSync(enPath, "utf-8"));

const locales = fs .readdirSync(LOCALES_DIR) .filter((f) => f !== DEFAULT_LOCALE);

for (const locale of locales) { const filePath = path.join(LOCALES_DIR, locale, "translation.json"); if (!fs.existsSync(filePath)) continue;

const translations = JSON.parse(fs.readFileSync(filePath, "utf-8"));

// Find keys with empty values const missing = Object.entries(translations) .filter(([key, value]) => !value || value === "") .map(([key]) => key);

if (missing.length === 0) { console.log(${locale}: all strings translated); continue; }

console.log(${locale}: translating ${missing.length} strings...);

// Get English source text for each missing key const sourceTexts = missing.map((key) => enStrings[key] || key);

// Translate in batches of 50 const BATCH_SIZE = 50; for (let i = 0; i < sourceTexts.length; i += BATCH_SIZE) { const batchKeys = missing.slice(i, i + BATCH_SIZE); const batchTexts = sourceTexts.slice(i, i + BATCH_SIZE);

const translated = await translateBatch(batchTexts, locale);

batchKeys.forEach((key, j) => { translations[key] = translated[j]; }); }

// Write back fs.writeFileSync(filePath, JSON.stringify(translations, null, 2) + "\n"); console.log(${locale}: done); } }

translateMissing().catch(console.error);

Run it:

AUTO18N_API_KEY=your-key node scripts/translate-missing.js

Output:

fr: translating 12 strings...
fr: done
de: translating 12 strings...
de: done
ja: translating 12 strings...
ja: done
es: translating 12 strings...
es: done

Your translation files are now populated.

Step 4: Add to your workflow

Option A: npm script. Add to package.json:

{
  "scripts": {
    "i18n:extract": "i18next-parser",
    "i18n:translate": "node scripts/translate-missing.js",
    "i18n:sync": "npm run i18n:extract && npm run i18n:translate"
  }
}

Run npm run i18n:sync after adding new UI strings. Commit the updated translation files.

Option B: Git hook. Run extraction on pre-commit so you never forget:

# .husky/pre-commit
npx i18next-parser
git add public/locales/

This ensures extraction runs every commit, but translation still happens manually (you'd run npm run i18n:translate separately since it requires API access).

Option C: CI pipeline. The most robust option — run the full sync on every PR:

# .github/workflows/i18n-sync.yml
name: Sync translations
on:
  push:
    branches: [main]
    paths:
      
  • "src/**"
  • "public/locales/en/**"
jobs: translate: runs-on: ubuntu-latest steps:
  • uses: actions/checkout@v4
  • uses: actions/setup-node@v4
with: node-version: 20
  • run: npm ci
  • run: npx i18next-parser
  • run: node scripts/translate-missing.js
env: AUTO18N_API_KEY: ${{ secrets.AUTO18N_API_KEY }}
  • name: Commit translations
run: | git config user.name "i18n-bot" git config user.email "i18n-bot@example.com" git add public/locales/ git diff --staged --quiet || git commit -m "chore: update translations" git push

Now when a developer adds t('New feature unlocked') in a component, the CI pipeline:

  • Extracts the new key into all locale files
  • Translates it into every target language
  • Commits the translations back to the repo
  • Zero manual intervention.

    Handling key changes and deletions

    What happens when you rename a key or remove one? i18next-parser handles additions well, but you need a strategy for cleanup:

    // In i18next-parser.config.js
    module.exports = {
      // ... other config
      keepRemoved: false, // Remove keys that are no longer in source code
    };

    With keepRemoved: false, keys that don't appear in any source file get removed from all translation files. This keeps your bundles clean but means you lose translations for removed keys. If you might re-add a key later, set this to true and periodically clean up manually.

    Handling context and plurals

    For strings with context or plurals, i18next uses conventions that the parser understands:

    // Plural
    t("item_count", { count: items.length });
    // Generates: "item_count_one", "item_count_other"
    

    // Context t("friend", { context: "male" }); // Generates: "friend_male", "friend_female"

    Your translation script needs to handle these variants:

    // Detect plural keys and translate with appropriate context
    if (
      key.endsWith("_one") ||
      key.endsWith("_other") ||
      key.endsWith("_few") ||
      key.endsWith("_many")
    ) {
      // Add plural context to the translation request
      const pluralForm = key.split("_").pop();
      // Translate with instructions about which plural form this is
    }

    What this gets you

    Before automation:

    • Developer adds a string
    • Developer (or PM) manually adds it to 5 translation files
    • Strings stay untranslated for days/weeks until someone notices
    • Translations get stale when source strings change
    After automation:
    • Developer adds a string
    • Translations appear in the next CI run (minutes)
    • New languages can be added by editing one config line
    • Every string is always translated
    The total setup time is about 30 minutes. The ongoing maintenance is near zero. The next time someone asks "how long to add Portuguese support?" the answer is "add 'pt' to the locale list and wait for CI."