DEV Community

Pavel Gajvoronski
Pavel Gajvoronski

Posted on

Translating 30 Pages into 12 Languages Without Losing Your Mind

We had 30 pages. All in English. All with hardcoded strings. A user pointed it out bluntly: "You translated the menu. What about everything else?"

Fair. Time to actually do it.

The Target

12 languages: English, German, French, Spanish (Spain), Spanish (Latin America), Italian, Portuguese, Russian, Polish, Japanese, Korean, Arabic.

Arabic adds RTL support. Japanese and Korean don't word-wrap the same way Western languages do. Latin American Spanish is different enough from Spain Spanish to warrant separate files.

30 pages × 12 languages × ~30 strings per page = roughly 10,800 translation entries. That's a lot of keys.

Architecture We Started With

The i18n system was already partially in place — nav items were translated. The infrastructure existed:

// src/lib/i18n.tsx
const I18nContext = createContext<I18nContextType | null>(null);

export function useI18n() {
  return useContext(I18nContext);
}
Enter fullscreen mode Exit fullscreen mode
// Usage in a component
const { t } = useI18n();
return <h1>{t('dashboard.title')}</h1>
Enter fullscreen mode Exit fullscreen mode

Translation files were TypeScript objects, not JSON. This matters: TypeScript gives you autocomplete on keys and catches typos at compile time, not runtime.

// src/lib/translations/en.ts
export const en = {
  nav: {
    dashboard: 'Dashboard',
    business: 'Business',
    // ...
  },
  dashboard: {
    title: 'Dashboard',
    // ...
  }
};
Enter fullscreen mode Exit fullscreen mode

What was missing: most pages were using hardcoded strings and ignoring the t() function entirely.

The Problem With Doing This After the Fact

When you build UI first and add i18n later, you discover that not every string is equally easy to extract.

Easy: Static labels, headings, button text, placeholder text. These drop into t() calls directly.

Annoying: Strings with dynamic values.

// Before
<p>Processing {count} items</p>

// After — naive approach that breaks in some languages
<p>{t('processing')} {count} {t('items')}</p>

// Better — interpolation
<p>{t('processing_count', { count })}</p>
// en.ts: processing_count: 'Processing {count} items'
// de.ts: processing_count: '{count} Elemente werden verarbeitet'
Enter fullscreen mode Exit fullscreen mode

German moves the verb. Japanese changes the word order entirely. If you split strings and concatenate them, word order is baked into code and you can't fix it in translations.

Tricky: Plural forms. English has singular/plural. Russian has four plural forms. Polish has three. Arabic has six.

We punted on full plural handling for v1 — most of our count strings are in contexts where the number is shown alongside the label and pluralization doesn't visually matter. We'll fix this properly later.

Skip entirely: Agent names, technical identifiers, API endpoint labels, icon names, CSS classes. These look like translatable text but aren't. Translating "GPT-4o" or "webhook_url" would break things.

The Scale Problem

Reading 30 page files manually to extract strings, then writing 12 × 30 translation file additions, is error-prone at this volume.

Our approach:

  1. Read each page file and extract all hardcoded strings
  2. Add keys to en.ts with appropriate values
  3. Add the same keys to all 11 other language files with translations
  4. Wire the pages to use t() instead of hardcoded strings

We ran extraction and translation in parallel using multiple agents — one auditing pages, others updating language files. The bottleneck was key naming: you need consistent conventions before parallelizing or you get collisions.

Key naming convention we settled on: page.element — e.g. dashboard.title, pricing.enterprisePlan, chat.placeholder. For shared components: common.save, common.cancel.

Flat enough to read, nested enough to avoid collisions.

What Actually Broke

Duplicate keys. When adding keys in parallel, two passes at the same file can create:

export const en = {
  dashboard: {
    title: 'Dashboard',    // added in pass 1
    title: 'Dashboard',    // added again in pass 2
  }
}
Enter fullscreen mode Exit fullscreen mode

TypeScript doesn't error on duplicate object keys by default — the second one silently wins. We caught these during the compile check. tsc --noEmit with "forceConsistentCasingInFileNames": true in tsconfig found them.

Missing keys in non-English files. We added keys to en.ts and forgot to add them to one of the 11 others. At runtime this fails silently — the key path returns undefined and you get nothing rendered.

Fix: after every batch of additions to en.ts, run a script that diffs the key structure against all other locale files and reports missing keys.

# Quick audit: count keys per file
for f in src/lib/translations/*.ts; do
  echo "$f: $(grep -c "'" $f) keys"
done
Enter fullscreen mode Exit fullscreen mode

Not perfect but good enough to spot files that fell way behind.

RTL layout. Arabic needs dir="rtl" on the root element. We detect the locale and set it:

<html lang={locale} dir={isRTL ? 'rtl' : 'ltr'}>
Enter fullscreen mode Exit fullscreen mode

But Tailwind's space-x-* and flex direction utilities don't automatically flip for RTL. We had a few layouts that looked wrong in Arabic because icons and text were in the wrong order. Most of these are still open — RTL is hard to get right without an Arabic speaker reviewing it.

What We'd Do Differently

Start with i18n scaffolding before building UI. Adding it after means touching every file twice — once to build, once to extract. If the t() call is part of your component template from day one, extraction becomes trivial: just add the translation value.

Use a dedicated i18n library for complex cases. We rolled our own minimal context provider. It's 80 lines and covers 90% of cases. But react-intl or next-intl handles pluralization, date/number formatting, and RTL better than our homebrew. For a product with global ambitions, the extra dependency is worth it.

Machine translate first, human edit later. We used AI translation for all 11 non-English files. Quality varies — French and German are solid, Japanese and Arabic need review by a native speaker. The right approach: MT gives you a baseline that's 80% correct, human review catches the errors. Don't ship MT output to production without review for languages you can't read.

Current State

  • 30 pages fully wired to t()
  • 12 languages with complete key coverage
  • ~800 translation keys in en.ts
  • RTL layout for Arabic (basic — needs review)
  • Zero hardcoded English strings in page files

What's still rough: plural forms, RTL edge cases, and translation quality review for non-Latin-script languages.

The user-facing result: switch the language in the top nav and every label, heading, button, and placeholder updates immediately. No page reload. The locale preference persists across sessions.


Kepion is an AI-powered company builder. One subscription gets you a full team of 31 specialized AI agents — strategy, content, development, marketing, finance — all orchestrated to build and run real businesses autonomously.


Over to you

Three things I'd love to hear from the community:

  1. How do you handle plural forms across languages? We punted on this for v1 — the four Russian forms and six Arabic forms are still TODO. Are you using Intl.PluralRules directly, a library like react-intl, or something else? What's the minimal solution that actually works?

  2. Do you use AI/MT for translation and then human review, or go straight to native speakers? We used AI translation for all 11 non-English files. Quality is uneven — I can't evaluate Japanese or Arabic without help. Curious what workflows others have found sustainable.

  3. Any tooling for keeping translation files in sync? We caught missing keys with a manual grep count. There's got to be a better way — i18n key audits, extract scripts, CI checks. What's in your pipeline?

Top comments (0)