Alexandr Chibilyaev on the internationalization architecture of AACFlow — next-intl with namespace-based JSON files, structural parity enforcement, Python translation scripts, and lessons learned from localizing 1,000+ keys across Russian, English, and German.
Most SaaS platforms ship in English first and "maybe add translations later." We took the opposite approach: internationalization was baked into AACFlow from the first commit. Today the platform ships simultaneously in Russian, English, and German — not as an afterthought, but as a first-class feature with 1,000+ translation keys maintained across three languages with structural parity enforcement.
Here's the architecture that makes it possible — and the lessons we learned building it.
I've seen what happens when a platform adds internationalization after reaching scale. It's not pretty.
The codebase is littered with hardcoded strings. "Just translate them" becomes a multi-month refactoring project. String interpolation breaks. Date formatting is inconsistent. Plurals are wrong. RTL languages require CSS rewrites. The SEO team discovers that half the pages have missing <html lang> attributes and duplicate canonical URLs.
The cost of retrofitting i18n onto an existing application is easily 3-5x higher than building it from day one. You're not just translating strings — you're unwinding assumptions baked into every component, every API response, every database query.
We didn't want that. So we started with three languages on day one.
After evaluating the i18n landscape for Next.js, we landed on next-intl. The decision wasn't taken lightly — the i18n library touches every page, every component, every API response. Switching later would be catastrophic.
Why next-intl over alternatives?
Native App Router support. At the time, next-i18next was stuck on Pages Router. next-intl was built for Server Components, streaming, and the App Router's routing model. That mattered — we weren't going to build on a library that couldn't keep up with the framework.
ICU message format.next-intl uses the ICU message syntax — the same standard used by major translation management systems. {count, plural, one {# item} other {# items}} is expressive, well-specified, and supported by translation tools. Simple key-value JSON wouldn't cut it for a platform with dynamic content.
Type safety. Translation keys are autocompleted. Missing keys are caught at compile time, not when a German user hits a 500 error. The defineMessages pattern means renaming a key updates every reference automatically.
Server Components by default. In the App Router, components are server-rendered by default. next-intl works with this model natively — translations are resolved on the server, rendered to HTML, and sent to the client. No flash of untranslated content. No hydration mismatches. No JavaScript bundle bloated with translation strings.
The routing structure is standard [locale] prefix:
1
/app/[locale]/
2
├──(marketing)/ — public pages
3
├──(dashboard)/ — authenticated workspace
4
├── api/ — APIroutes(locale-agnostic)
5
└── layout.tsx — root layout with NextIntlClientProvider
Three locales: ru (Russian), en (English), de (German). Russian is the default because the majority of our users are Russian-speaking. English and German are the expansion targets — English for the global developer market, German for the DACH enterprise market.
The critical rule: every key in en/common.json must exist in ru/common.json and de/common.json. No missing keys. No extra keys. Structural parity across all three locales.
A missing key in German means a production error. An extra key in Russian means a string the translators never saw. Both are bugs, and both are caught before they reach production.
The namespace structure mirrors the application's component hierarchy. editor.json contains all strings used by the visual workflow editor. connectors.json covers connector configuration. This means when we update the editor, the translator only needs to review editor.json — not wade through 1,000 unrelated dashboard strings to find the 5 that changed.
Manually checking 1,000+ keys across three languages for parity is impossible. Humans miss things. So we wrote a Python script — scripts/sync-i18n.py — that enforces structural parity programmatically.
New key detection. When a developer adds a key to the English file, the script identifies it, adds placeholder entries to Russian and German files, and flags them for translation review.
Unused key detection. Keys that exist in the JSON files but are no longer referenced in the codebase are flagged for removal. Translation rot is real — strings accumulate over time, and without automated cleanup, your translation files become graveyards of deprecated features.
ICU syntax validation. The script validates that ICU message syntax is correct in all three locales. A mismatched plural form or missing argument in German won't surface until a user hits it — unless the script catches it first.
Sorting and formatting. Keys are kept alphabetically sorted within each namespace. Indentation is consistent. Trailing commas are normalized. This eliminates meaningless diffs when translators touch files.
The script runs in CI. If parity is broken, the build fails. No exceptions. No "we'll fix it later." Structural parity is a build-time invariant.
The marketing blog — the posts you're reading now — follows a different localization strategy. Blog posts are long-form content written by human authors, not UI strings composed of reusable keys. Machine translation would produce awkward, unprofessional results. So we localize the entire post:
1
content/blog/
2
├── en/
3
│ ├── why-i-built-aacflow/
4
│ │ ├── index.mdx
5
│ │ └── cover.png
6
│ └── observability/
7
│ ├── index.mdx
8
│ └── cover.png
9
├── ru/
10
│ ├── why-i-built-aacflow/
11
│ │ ├── index.mdx
12
│ │ └── cover.png
13
│ └── observability/
14
│ ├── index.mdx
15
│ └── cover.png
16
└── de/
17
├── why-i-built-aacflow/
18
│ ├── index.mdx
19
│ └── cover.png
20
└── ...
Each locale directory contains the full post in that language. The MDX frontmatter is localized: title, description, ogAlt, and about fields are translated. The slug stays the same across locales — it's a content identifier, not a user-facing string. The canonical URL points to the correct locale. Tags remain in English (they're used for cross-locale filtering).
This model works well for long-form content where quality matters more than automation. The blog is not a template — it's editorial content. Each translation is a human-reviewed, human-edited piece of writing.
Authors aren't monolithic entities with a single name. An author might be known differently in different linguistic communities:
1
// authors/alexandr.json
2
{
3
"name":"Alexandr Chibilyaev",
4
"nameRu":"Александр Чибиляев",
5
"nameDe":"Alexandr Tschibiljajew",
6
"avatar":"/authors/alexandr.jpg",
7
"role":"Founder & CEO",
8
"roleRu":"Основатель и CEO",
9
"roleDe":"Gründer & CEO"
10
}
The blog rendering logic picks the localized name based on the current locale. A Russian reader sees "Александр Чибиляев." A German reader sees "Alexandr Tschibiljajew" — the proper transliteration of the Russian name into German orthography, not a lazy copy-paste of the English version.
This attention to detail matters. When a German user sees their language treated with respect — proper name forms, correct grammatical structures, culturally appropriate examples — they understand that the entire platform takes localization seriously, not just the marketing pages.
Every page declares its canonical URL and its alternate-language URLs via hreflang tags. Search engines see: "This is the English version. The Russian version is over there. The German version is over there. They're all the same content in different languages — don't penalize us for duplicate content."
This is table stakes for international SEO, but it's surprising how many platforms get it wrong — missing hreflang tags, self-referencing canonicals with wrong locales, or (worst) marking all language variants as duplicates and tanking their search rankings.
Blog posts need to know their own URL across locales. A post slug like observability maps to:
/en/blog/observability in English
/ru/blog/observability in Russian
/de/blog/observability in German
But the URL structure isn't always a simple prefix swap. Some slugs differ between locales (rare, but it happens for culturally specific content). The getLocalizedBlogPath helper handles this:
The BLOG_SLUG_MAP is a small lookup table — almost always identity maps, but providing an escape hatch for the rare case where a slug needs localization. This avoids polluting the MDX frontmatter with locale-specific URL logic.
This is the single most important lesson. Adding i18n to an existing codebase is a multi-month refactoring project. Adding it from day one is a few hours of setup and a discipline you maintain.
Every new component we build starts with the question: "What will this look like in Russian, English, and German?" The strings go into the namespace JSON files before the component is merged. There is no "I'll add translations later" — later never comes.
I've consulted for companies that tried to retrofit i18n. The pattern is always the same:
Month 1-2: Extract all hardcoded strings into a translation file. Discover that 40% of them are dynamic — constructed from variables, formatted with locale-specific logic, embedded in JSX in ways that resist simple extraction.
Month 3: Fix the broken dates. Russian formats dates as DD.MM.YYYY. English as MM/DD/YYYY (or DD/MM/YYYY depending on region). German as DD.MM.YYYY with dots. Every date display breaks.
Month 4: Fix the broken plurals. English has two plural forms (1 item, 2 items). Russian has three (1 элемент, 2 элемента, 5 элементов). Your "if count === 1" logic is wrong for half your languages.
Month 5: Fix the broken layouts. German words are longer than English words by 30-50% on average. Your carefully designed buttons, tables, and cards now overflow. Every flexbox needs rethinking.
Month 6: Fix the SEO. Missing hreflang tags. Wrong canonicals. Search engines have been penalizing you for duplicate content for six months.
Total cost: 6+ months of engineering time, hundreds of bugs filed by international users, and a search ranking hit that takes months to recover from. Total cost of doing it from day one: maybe 2-3 extra days of work spread across the first few weeks of development.
Developers don't translate. Translators don't code. The interface between them — the translation management workflow — is where most i18n projects fail.
We learned that translators need:
Context. A JSON key submitButton with value "Submit" is meaningless without knowing where it appears. Is it a form submit? A modal confirmation? A dangerous action? We now embed comments in the JSON files describing the context: "_comment": "Appears on the billing page — confirms a purchase, not a data submission".
Diff visibility. When the English file changes, translators need to see exactly what changed — not a full file diff where 95% of the content is identical. The sync script generates a "changed keys" report showing only the 3 keys that were modified, with old and new English values side by side.
Preview capability. Translators should see their translations in context before they go live. We run a staging environment where every branch can be previewed in all three locales. A translator can check their German strings in the actual UI before merging.
Consistency is the hardest problem in i18n. The same concept should use the same term everywhere. "Workspace" shouldn't be "Arbeitsbereich" on one page and "Workspace" on another. "Execute" shouldn't be "Выполнить" in the editor and "Запустить" on the dashboard.
Our approach: a shared glossary file that maps English terms to their canonical translations:
The sync script flags any translation that deviates from the glossary. If a translator uses "Agent" for "агент" in 95% of cases but "робот" in 5%, the script catches the inconsistency. This prevents the slow drift where translation quality degrades as different people touch different files.
Internationalization is not a feature. It's an architectural decision. It affects your routing, your component design, your SEO, your testing, your CI pipeline. It cannot be added later — not without pain far exceeding the cost of doing it right from the start.
AACFlow ships in three languages simultaneously not because we have a large translation team (we don't), but because we built the infrastructure to enforce consistency, catch errors at build time, and give translators the tools they need to work efficiently.
If you're building a SaaS platform today, start with at least two languages. Even if your initial market is monolingual. The architectural decisions you make in the first month — [locale] routing, namespace-based message files, ICU syntax, locale-aware metadata — cost almost nothing to implement early and cost a fortune to retrofit later.
The world doesn't speak one language. Your platform shouldn't either.