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.
