Simplest Web App Pattern: Multilingual Output and Fallback APIs
A pragmatic web app pattern for multilingual output: wiring translation APIs into the render path, handling errors and timeouts, and the fallback chain that keeps users from ever seeing raw message keys.

Most web apps bolt on internationalization as an afterthought, hardcoded strings get extracted late, API calls lack error handling, and a single translation timeout leaves users staring at raw message keys. The result is a fragile pipeline that breaks silently in production. This guide gives you a copy-ready, minimal integration pattern for multilingual output in a Node/Next.js stack. You will learn how to detect locale, fetch translated strings through a translation API, preserve placeholders, cache results, and fall back gracefully when things go wrong. The pattern includes retry logic with exponential backoff, circuit breaking, glossary enforcement, and a test suite that catches regressions before they ship. Every code sample is production-oriented and framework-light enough to adapt to any server-rendered or edge-deployed app.
If you are evaluating how a managed translation API fits into this architecture, explore how Ollang handles the API layer end-to-end.
Detecting Locale and Selecting Target Language
Locale detection is the entry point. Get it wrong and every downstream translation call targets the wrong language. In a Next.js app, locale information arrives from three sources, and you should evaluate them in priority order:
- Explicit user preference, a cookie or profile setting the user has chosen.
- URL segment or query parameter, /fr/pricing or ?lang=fr.
- Accept-Language header, the browser's language list, parsed and matched against your supported locales.
Parsing Accept-Language with quality values
The Accept-Language header contains a comma-separated list of language tags with optional quality values (e.g., fr-CA,fr;q=0.9,en;q=0.5). Parse it into a sorted array and match against your supported locale set:
function parseAcceptLanguage(header, supported) {
const parsed = header
.split(',')
.map(entry => {
const [tag, q] = entry.trim().split(';q=');
return { tag: tag.trim(), quality: q ? parseFloat(q) : 1.0 };
})
.sort((a, b) => b.quality - a.quality);
for (const { tag } of parsed) {
const match = supported.find(
s => s === tag || s.startsWith(tag.split('-')[0])
);
if (match) return match;
}
return null; // no match
}
Defining a fallback locale chain
A single fallback locale is rarely enough. Define a chain: if fr-CA strings are unavailable, try fr, then en. Store this mapping in a simple config object:
const FALLBACK_CHAINS = {
'fr-CA': ['fr', 'en'],
'de-AT': ['de', 'en'],
'ja': ['en'],
'en': [],
};
The resolved locale and its fallback chain travel together through every subsequent function.
Fetching Translated Strings via a Translation API
Once you have a target locale, the next step is retrieving the translated strings for that locale and namespace. A clean request shape keeps the integration portable across translation providers.
Request shape: namespace, locale, and format
Structure your API call around three parameters: a namespace (the logical group of strings, such as homepage or checkout), the target locale, and the desired response format. A typical request body looks like this:
{
"namespace": "checkout",
"source_locale": "en",
"target_locale": "fr-CA",
"format": "key-value",
"options": {
"include_glossary": true,
"preserve_placeholders": true
}
}
Setting preserve_placeholders to true instructs the API to leave ICU and HTML tokens intact in the translated output. Setting include_glossary applies any terminology rules you have configured, brand names, product terms, legal phrases, so translators and machine translation engines do not freestyle on controlled vocabulary.
Response shape and key-value mapping
The response should be a flat or nested key-value map that your rendering layer can consume directly:
{
"locale": "fr-CA",
"namespace": "checkout",
"keys": {
"checkout.title": "Passer la commande",
"checkout.item_count": "Vous avez {count, plural, one {# article} other {# articles}} dans votre panier.",
"checkout.terms": "En continuant, vous acceptez nos <a href=\"/terms\">conditions</a>."
}
}
Notice the ICU plural syntax and the inline HTML are preserved exactly as authored. This is critical; any API that strips or re-encodes these tokens will break your rendering.
Preserving Placeholders: HTML and ICU Safety
Placeholder corruption is the single most common bug in translated output. A translation engine that does not understand ICU MessageFormat or HTML markup will mangle curly braces, break tag nesting, or drop variables entirely.
Why placeholder preservation matters
Consider the ICU string {userName} added {count, plural, one {# item} other {# items}}. If the translation API treats {userName} as translatable text, the French output might contain {nomUtilisateur}, which your formatter will not resolve, producing a raw key on screen. Similarly, an unmatched <b> tag in translated HTML will cascade layout errors across the page.
Validating returned strings
After every API response, run a lightweight validation pass:
function validatePlaceholders(source, translated) {
const icuPattern = /\{[^}]+\}/g;
const sourceTokens = (source.match(icuPattern) || []).sort();
const translatedTokens = (translated.match(icuPattern) || []).sort();
if (JSON.stringify(sourceTokens) !== JSON.stringify(translatedTokens)) {
return { valid: false, missing: sourceTokens.filter(t => !translatedTokens.includes(t)) };
}
return { valid: true };
}
For HTML safety, confirm that every opening tag in the source has a corresponding closing tag in the translation, and that no new tags have been injected. Libraries like htmlparser2 can do this cheaply at the string level without a full DOM parse.
Glossary enforcement in the request
When your API supports glossary parameters, always send them. A glossary entry like { "source": "Ollang", "target": "Ollang", "case_sensitive": true } prevents the translation engine from transliterating or translating brand terms. This is especially important for legal and compliance content where specific terminology must remain unchanged across languages.
Building a Slim Abstraction Layer
Wrapping your translation API calls in a thin service layer keeps the rest of your application code clean and testable. This layer handles retries, backoff, circuit breaking, and fallback resolution in one place.
Core fetch wrapper with retry and exponential backoff
async function fetchWithRetry(url, options, { maxRetries = 3, baseDelay = 200 } = {}) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(timeout);
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') || '1', 10);
await sleep(retryAfter * 1000);
continue;
}
if (!res.ok) throw new Error(`API ${res.status}`);
return await res.json();
} catch (err) {
lastError = err;
if (attempt < maxRetries) {
await sleep(baseDelay * Math.pow(2, attempt));
}
}
}
throw lastError;
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
Key behaviors:
- A hard 5-second timeout per request via AbortController.
- Respect for 429 Too Many Requests with the server's Retry-After header.
- Exponential backoff: 200 ms → 400 ms → 800 ms between retries.
Circuit breaker pattern
If the translation API is down, hammering it with retries across every incoming request will saturate your connection pool. A minimal circuit breaker tracks consecutive failures and short-circuits calls for a cooldown period:
class CircuitBreaker {
constructor({ threshold = 5, cooldown = 30000 } = {}) {
this.failures = 0;
this.threshold = threshold;
this.cooldown = cooldown;
this.openedAt = null;
}
isOpen() {
if (this.failures < this.threshold) return false;
if (Date.now() - this.openedAt > this.cooldown) {
this.failures = 0; // half-open: allow one probe
return false;
}
return true;
}
recordFailure() {
this.failures++;
if (this.failures >= this.threshold) this.openedAt = Date.now();
}
recordSuccess() {
this.failures = 0;
this.openedAt = null;
}
}
When the circuit is open, your abstraction layer skips the API call entirely and falls back to the next locale in the chain or to cached source strings.
Fallback resolution across the locale chain
Tie everything together in a single getTranslations function:
const breaker = new CircuitBreaker();
// `cache` is any Map-like with get/set/delete (e.g., an LRU instance)
async function getTranslations(namespace, locale) {
const chain = [locale, ...(FALLBACK_CHAINS[locale] || ['en'])];
for (const target of chain) {
const cached = cache.get(`${namespace}:${target}`);
if (cached) return cached;
if (breaker.isOpen()) continue;
try {
const data = await fetchWithRetry(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
namespace,
target_locale: target,
source_locale: 'en',
options: { preserve_placeholders: true, include_glossary: true }
}),
});
breaker.recordSuccess();
cache.set(`${namespace}:${target}`, data.keys);
return data.keys;
} catch (err) {
breaker.recordFailure();
// continue to next locale in chain
}
}
// ultimate fallback: return source keys as-is
return cache.get(`${namespace}:en`) || {};
}
Decision point: If you’d rather not maintain retry, circuit-breaking, glossary enforcement, and fallback logic yourself, you can offload this layer to a managed provider, see how Ollang’s integration plugs into this pattern.
Ready to see Ollang in action?
Talk to our team about your localization goals and see how the Ollang platform fits your workflow.
Caching Strategies and Cache Invalidation
Translation strings change far less frequently than application data. Aggressive caching is not just safe, it is essential for keeping response times low and API costs down.
In-memory and edge caching
For server-rendered Next.js apps, an in-memory LRU cache keyed by namespace:locale handles the majority of requests. A TTL of 5-15 minutes is a practical starting point:
const LRU = require('lru-cache');
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 10 }); // 10-minute TTL
At the edge (Vercel Edge Functions, Cloudflare Workers), use the platform's cache API or KV store. The key is the same namespace:locale pair; the TTL can be longer because edge invalidation is typically event-driven.
When to invalidate
Cache invalidation should be triggered by translation events, not by timers alone. Common triggers:
| Trigger | Mechanism |
|---|---|
| New translations published | Webhook from translation platform purges affected keys |
| Glossary update | Invalidate all locales for affected namespaces |
| Deployment | Bust cache as part of the CI/CD pipeline |
| Manual override | Admin endpoint that clears a specific namespace:locale pair |
When your translation API supports webhooks, subscribe to publication events and call cache.delete() for the relevant entries. This gives you near-instant propagation without polling.
Prebuild Bundles vs. On-Demand Fetch
The choice between shipping translation bundles at build time and fetching them at runtime is not binary. Most production apps use a hybrid approach.
Prebuilt bundles at build time
Pull all translations during next build and write them to static JSON files. This eliminates runtime API dependencies entirely for those strings:
- Fastest possible page load, strings are already in the bundle.
- Zero runtime API calls for static pages.
- Downside: any translation update requires a redeploy.
This approach works well for marketing pages, legal copy, and any content that changes on a release cadence.
On-demand fetch at runtime
Dynamic content, user-generated labels, admin-configured strings, frequently updated product descriptions, should be fetched at request time (or on first miss from cache). This keeps content fresh without redeploying.
Hybrid pattern
Use prebuilt bundles as the baseline and layer on-demand fetch for namespaces that change often. Your getTranslations function checks the static bundle first, then falls back to the API:
async function getStringsForPage(namespace, locale) {
let bundled = null;
try {
bundled = (await import(`../locales/${locale}/${namespace}.json`)).default;
} catch (_) {
// bundle for this namespace/locale not present
}
if (bundled) return bundled;
return getTranslations(namespace, locale); // API fetch with cache + fallback
}
This gives you the reliability of static bundles with the freshness of runtime fetch, and the fallback chain ensures the user always sees translated content.
Testing: Snapshots, Placeholder Integrity, and Coverage
Translation bugs are silent. A missing key renders an empty string; a broken placeholder shows {0} to the user. Automated tests catch these before they reach production.
Snapshot tests for translation keys
Generate a snapshot of all expected keys per namespace. On every CI run, compare the current key set against the snapshot:
// __tests__/translations.test.js
const expectedKeys = require('../fixtures/checkout-keys.json');
test('checkout namespace has all required keys', async () => {
const translations = await getTranslations('checkout', 'fr');
const returnedKeys = Object.keys(translations).sort();
expect(returnedKeys).toEqual(expectedKeys.sort());
});
If a key is added or removed, the snapshot diff surfaces it in the pull request review.
Placeholder integrity checks
Run the validatePlaceholders function from earlier across every key-value pair for every locale:
const sourceStrings = require('../locales/en/checkout.json');
test.each(Object.entries(sourceStrings))('placeholder integrity for key "%s"', (key, source) => {
const translated = frenchStrings[key];
const result = validatePlaceholders(source, translated);
expect(result.valid).toBe(true);
});
This test fails immediately if a translation drops a {variable} or introduces a mismatched HTML tag.
Edge-case test scenarios
| Test case | What it validates |
|---|---|
| API returns 500 on first call | Retry logic fires; fallback locale is used |
| API times out | AbortController triggers; circuit breaker increments |
| Circuit breaker is open | API is not called; cached or fallback strings are returned |
| Unknown locale requested | Falls back to source locale without error |
| Empty response body | Treated as a failure; next locale in chain is tried |
Production Checklist
Before shipping this pattern, walk through this checklist:
- Environment variables, API key, base URL, and default locale are set in all environments (dev, staging, production).
- Timeout tuned, The 5-second timeout is appropriate for your API's p99 latency. Adjust if your provider is faster or slower.
- Circuit breaker thresholds, Five consecutive failures and a 30-second cooldown are reasonable defaults. Monitor and adjust based on observed error rates.
- Cache TTL aligned with publish cadence, If translations are published daily, a 10-minute TTL is fine. If they change hourly, shorten it or rely on webhook-driven invalidation.
- Glossary configured, Brand terms, legal phrases, and product names are loaded into the translation API's glossary before the first production request.
- Placeholder validation in CI, The integrity test suite runs on every pull request. Failures block merge.
- Monitoring and alerting, Log every fallback event (locale miss, circuit open, cache miss). Alert on sustained fallback to source locale, it means your users are seeing untranslated content.
- Content Security Policy, If fetching translations client-side, ensure your CSP allows connections to the translation API domain.
- Graceful degradation, Confirm that when every API call fails and the cache is cold, the app renders source-language strings rather than crashing or showing empty content.
Frequently Asked Questions
How do I handle right-to-left (RTL) locales in this pattern?
The translation fetch pattern itself does not change for RTL languages like Arabic or Hebrew. The API returns translated strings in the same key-value format regardless of script direction. RTL handling is a rendering concern: set the dir="rtl" attribute on your HTML element based on the resolved locale, and ensure your CSS uses logical properties (margin-inline-start instead of margin-left). The locale detection step already gives you the information you need to make this decision.
Should I use synchronous or asynchronous translation API calls?
For server-rendered pages in Next.js (via getServerSideProps or route handlers), use asynchronous calls with the retry and timeout logic described above. Synchronous calls would block the event loop and destroy throughput. For static site generation (getStaticProps), the calls happen at build time and are inherently async. Client-side fetches should also be async, typically triggered on route change and gated behind a loading state or skeleton UI. Ollang’s APIs and client patterns are designed for asynchronous, non-blocking usage in these server and edge workflows.
What happens if a translation key exists in the source but not in the target locale?
Your getTranslations function returns whatever keys the API provides. If a key is missing from the fr-CA response, you have two options: fall back to the next locale in the chain (try fr, then en), or merge the target response with the source strings so that missing keys default to English. The merge approach is simpler and avoids an extra API call:
const merged = { ...sourceStrings, ...translatedStrings };
This guarantees every key resolves to something displayable.
How often should I rotate my translation API key?
Follow your organization's secret rotation policy, typically every 90 days. Store the key in a secrets manager (AWS Secrets Manager, HashiCorp Vault, Vercel Environment Variables), not in source code. When rotating, support both the old and new key simultaneously for a brief overlap window to avoid downtime during deployment.
Ready to see Ollang in action?
Talk to our team about your localization goals and see how the Ollang platform fits your workflow.
Get Started with a Resilient Translation API Layer
The pattern above gives you a production-grade starting point: locale detection, API fetch with retry and circuit breaking, placeholder validation, caching with event-driven invalidation, and a test suite that catches regressions in CI. It is intentionally minimal so you can adapt it to your stack without fighting framework opinions.
Published on July 30, 2026