Volver al blog
Five languages from day one: internationalization in a full-stack monorepo
Engineering

Five languages from day one: internationalization in a full-stack monorepo

8 min read|14 de mayo de 2026|Neural Summary

Neural Summary supports five languages: English, Dutch, German, French, and Spanish. Not just the UI buttons and labels, but the AI-generated content. Every summary, every lens output, every chat response respects the user's language preference.

We added i18n on day one. The first commit included multi-language support. This was the best architectural decision we made, and this post explains why, how we implemented it across the full stack, and what makes AI-content localization different from traditional i18n.

Three layers of language

Traditional internationalization has one layer: UI strings. Button labels, error messages, page titles. You put them in translation files and swap them based on locale.

Our system has three layers:

Layer 1: UI strings. Standard i18n. Labels, buttons, navigation, error messages, tooltips. Five JSON translation files, one per locale.

Layer 2: AI-generated content. Summaries, lens outputs, and chat responses generated in the user's language. This requires language-aware prompts, not just string replacement.

Layer 3: Content translation. Users can translate an existing summary or lens output into any of the five supported languages. This is on-demand translation using an LLM, not pre-computed.

Each layer has different technical requirements and different failure modes.

Layer 1: UI strings

The frontend uses an i18n library with locale-based routing. URLs include the locale: /en/dashboard, /nl/dashboard, /de/dashboard. The middleware detects the locale from the URL and loads the corresponding translation file.

Each locale has a JSON file with nested keys:

{
  "dashboard": {
    "greeting": "Good morning",
    "conversations": "Conversations",
    "folders": "Folders"
  },
  "lens": {
    "apply": "Apply a lens",
    "generating": "Generating..."
  }
}

 

Components use a hook to access translated strings:

const t = useTranslations('dashboard');
return <h1>{t('greeting')}, {user.name}</h1>;

 

This is standard. Every modern framework supports it. The interesting challenges are in the other two layers.

The capitalization problem

Different languages have different capitalization rules. This matters for headings, labels, and UI text.

English: Sentence case for most UI text. "How it works" not "How It Works."

German: Nouns are always capitalized. "So Funktioniert es" where "Funktioniert" is not capitalized (it is a verb) but a heading like "Wichtigste Erkenntnisse" capitalizes "Erkenntnisse" (it is a noun).

Dutch, Spanish, French: Similar to English. Only capitalize the first word and proper nouns.

Our translation files follow each language's rules. We do not apply a global capitalization function. Each translated string is capitalized correctly for its language by the translator (human or AI-assisted).

The worst bug we encountered: the i18n library was auto-detecting browser language preferences and overriding the user's explicit choice. A Dutch user who selected English would see Dutch on their next visit because their browser's Accept-Language header listed nl first. We disabled browser detection and rely solely on the URL locale and a preference cookie.

Layer 2: AI-generated content

This is where i18n gets interesting.

When Neural Summary generates a summary or lens output, the content must be in the user's language. Not just the prose sections, but every field: headings, descriptions, action item text, labels, chart titles.

We inject a language requirement into every LLM prompt:

CRITICAL LANGUAGE REQUIREMENT: You MUST generate ALL JSON text 
values in German. This includes all headings, descriptions, 
content, labels, and any other text. Do NOT fall back to English 
for any field.

 

Without this injection, the LLM defaults to English for roughly 30% of fields in non-English outputs. Structural text like section headers and labels is particularly prone to English fallback, because the model has seen more English examples of those patterns.

The emphasis (CRITICAL, MUST, ALL, Do NOT) is deliberate and tested. Softer formulations produced significantly more English leakage.

Template-level language awareness

Each lens template has language-specific considerations beyond the general injection.

Action items must use imperative verbs in the target language. English: "Ship the pricing page." German: "Preisseite versenden." The verb form differs by language, and the LLM needs explicit instruction to use the imperative rather than the infinitive or subjunctive.

Professional terminology varies by language. A "strategy brief" is a "Strategiebrief" in German, "note stratégique" in French, "nota estratégica" in Spanish. We do not translate these terms ourselves. We instruct the LLM to use the professional terminology natural to the target language.

Date and number formatting follows locale conventions. German uses periods for thousands separators and commas for decimals (1.234,56). English uses the opposite (1,234.56). The rendering layer handles this, not the LLM.

Layer 3: On-demand translation

Users can translate an existing summary or lens output into any of the five supported languages. This is separate from generating in a specific language.

The translation pipeline:

The translation preserves the JSON structure. A translated executive summary has the same schema as the original, with every string value translated. This means the same rendering component works for any language.

Parallel translation

When a user requests a translation, we translate the summary and all generated lens outputs simultaneously using parallel API calls. A conversation with a summary and three lens outputs sends four translation requests concurrently.

await Promise.all([
  translateSummary(transcriptionId, targetLanguage),
  ...lensOutputs.map(lens => 
    translateLens(lens.id, targetLanguage)
  ),
]);

 

Parallel translation means a conversation with five lens outputs translates in roughly the same time as a conversation with one. The LLM API calls run concurrently.

Translation preference persistence

When a user translates a conversation to French, we store that preference. The next time they open the conversation, it automatically displays in French. This is stored per-conversation, not globally, because a user might want their French client meetings in French and their internal team meetings in English.

Shared transcript translations

When a user shares a conversation via link, all existing translations are included in the shared view. The recipient can switch between languages without triggering new translation requests.

This required a design decision: do we translate on demand for the recipient, or do we include all existing translations at share time?

We chose the latter. Recipients get read-only access to whatever translations the sender has already generated. No API calls from the shared view. No cost for the recipient. And no latency: language switching is instant because the data is already present.

The monorepo advantage

Having the frontend, backend, and shared types in a single monorepo made i18n significantly easier.

The Transcription type in the shared package defines the preferredTranslationLanguage field. Both frontend and backend import this type. When we added the field, TypeScript immediately flagged every place in both applications that needed to handle it.

The supported locales list (['en', 'nl', 'de', 'fr', 'es']) is defined once in a shared configuration. Both the frontend routing and the backend translation service import it. Adding a sixth language would require changing one file and adding one translation JSON file.

What we would do differently

Introduce human sense-checks earlier. We initially trusted AI translations for UI strings and only reviewed them when something looked off. Introducing native-speaker spot-checks from the start, even informally, would have caught awkward phrasing and false-friend translations before they reached production.

Add a translation memory system. Our current system translates each piece of content independently. If the same phrase appears in 50 conversations, it is translated 50 times. A translation memory that caches common phrases and maintains consistency across translations would reduce costs and improve quality.

Test German more aggressively. German capitalization, compound word formation, and formal/informal address conventions make it the hardest language in our set. We found and fixed German-specific issues throughout development. A German-speaking tester from day one would have caught these earlier.

What this changed about our architecture

Adding i18n on day one cost us a few extra hours per feature. Each new component was built with translated strings. Each new prompt included the language injection. Each new data field was designed to support translations. That small incremental effort compounded into something significant.

Here is what we got for free by starting early:

  1. 1

    Every new feature ships in five languages automatically. There is no separate localization sprint. The translated strings are written alongside the component. The language-aware prompt is part of the template. It is just how we build.

  2. 2

    The architecture never needs retrofitting. URL routing already includes locale prefixes. Database schemas already support multilingual content. AI prompts already inject language requirements. None of these need to be added after the fact.

  3. 3

    SEO works across markets from launch. Each locale has its own metadata, structured data, and hreflang tags. We rank in five languages without a separate internationalization project.

  4. 4

    The codebase stays clean. No hardcoded strings to extract. No rendering components to retrofit. No prompt rewrites to schedule. The i18n patterns are baked into the way we write code, not bolted on.

We have heard from other teams that adding i18n months after launch takes weeks of refactoring with high regression risk. By investing a few hours per feature from the start, we avoided that entirely. It is one of the clearest cases for early architectural investment we have encountered.

Five languages. Three layers. Built from the first commit. We have not regretted it once.

Seguir leyendo