Enterprise i18n at Scale
- Client
- Confidential Healthcare Client
- Overview
Built a comprehensive internationalization system supporting 9 languages across ~1,800 pages using Next.js 16 and next-intl 4.4. This case study examines the architectural decisions, translation pipeline, and performance optimizations required to deliver a type-safe, maintainable i18n solution at enterprise scale.
- Technologies Used
- i18nNext.jsTypeScriptPayload CMSnext-intlLocalization

Executive Summary
Building a comprehensive internationalization system supporting 9 languages across ~1,800 pages using Next.js 16 and next-intl 4.4
Framework
i18n
Strategy
Build Time
First Load
Translations/Page
TTFB
The Challenge
This case study documents the architecture, challenges, and solutions for building a multilingual healthcare platform where translation precision is critical and performance is non-negotiable.
Architecture Overview
Technology stack decisions and version choices
// Core i18n setup
next-intl 4.4.0 // Routing + translation management
Next.js 16.0.1 // App Router with [locale] dynamic segment
React 19.2.0 // Latest React with modern features
Payload CMS 3.62 // Headless CMS with built-in localization
TypeScript 5 // Type safety for translation keys
Tailwind CSS 4.0.9 // Styling frameworkLocale-based routing with English as the default (no prefix)
/ → English (default)
/es → Spanish
/fr → French
/pt → Portuguese
/sw → Kiswahili
/hi → Hindi
/tl → Tagalog
/lg → Luganda
/yo → YorubaExample Page Routes
/about-us → English about page
/es/about-us → Spanish about page
/fr/about-us → French about page
...and so on for all 38 page templatesRequest Flow
/es/about-us
Extract locale from URL segment
Serve pre-generated HTML
100% Static
All pages generated at build time via generateStaticParams()
Zero Runtime Cost
No API calls, no TMS fees, no translation loading
CDN Cached
TTFB under 50ms with edge network distribution
The Single Source of Truth Pattern
Define all locales once and derive both Next.js and Payload CMS configurations from it
// localization.ts - Single source of truth for ALL locale configuration
const localeDefinitions = [
{ label: "English", code: "en" },
{ label: "Spanish", code: "es" },
{ label: "French", code: "fr" },
{ label: "Portuguese", code: "pt" },
{ label: "Kiswahili", code: "sw" },
{ label: "Hindi", code: "hi" },
{ label: "Tagalog", code: "tl" },
{ label: "Luganda", code: "lg" },
{ label: "Yoruba", code: "yo" },
] as const;
export const localization = {
locales: localeDefinitions,
defaultLocale: "en" as const,
fallback: true, // Fall back to English for missing translations
};
// For Payload CMS - derived from single source
export const payloadLocalization = {
locales: localeDefinitions.map((l) => ({
label: l.label,
code: l.code,
})),
defaultLocale: "en" as const,
fallback: true, // CMS can gracefully degrade
};
export type LocaleCode = (typeof localeDefinitions)[number]["code"];New languages propagate to Next.js and Payload automatically
Impossible to have mismatched locale lists
TypeScript ensures valid locale codes across codebase
Modified one array in localization.ts. No build errors, no config debugging, no production issues.
Never had a mismatch between Next.js routing and CMS locale options across 9 languages.
Both systems import from the same file. The pattern scales to any number of systems requiring locale configuration.
Next.js Configuration
import { localization } from './localization';
// next.config.js
export default {
i18n: {
locales: localization.locales.map(l => l.code),
defaultLocale: localization.defaultLocale,
}
}Payload CMS Configuration
import { payloadLocalization } from './localization';
// payload.config.ts
export default buildConfig({
localization: payloadLocalization,
})Hybrid Translation Strategy
Combining static JSON files for UI with Payload CMS for dynamic content
Static JSON
Payload CMS
Translation File Organization
Colocated translations next to page components for maintainability
JSON Structure Patterns
Handling inline formatting without HTML in JSON
{
"part1": "We provide",
"bold": "support",
"part2": "worldwide."
}<p>
{t('part1')}{' '}
<strong>{t('bold')}</strong>{' '}
{t('part2')}
</p>We provide support worldwide.
Brindamos soporte en todo el mundo.
{ "part1": "Brindamos", "bold": "soporte", "part2": "en todo el mundo." }Use semantic names that describe content purpose, not HTML structure. Think like a headless CMS: field names should make sense to content editors, not just developers.
JSON = content. Component code = structure. Translators should understand what they're translating without knowing HTML. This mirrors how headless CMS editors work: they see "heroTitle" and "ctaButtonText", not "h1" and "button".
Namespace Architecture at Scale
Preventing key collisions across 290+ translation files
hero.title keys → inevitable collisions and hard-to-debug errors.Each page gets its own namespace, eliminating collision risk entirely
const messages = {
homepage: (await import(`@/app/[locale]/_data/${locale}.json`)).default,
aboutUs: (await import(`@/app/[locale]/about-us/_data/${locale}.json`)).default,
contactUs: (await import(`@/app/[locale]/contact-us/_data/${locale}.json`)).default,
blog: (await import(`@/app/[locale]/blog/_data/${locale}.json`)).default,
// ... 38 pages total
...sharedMessages, // header, footer (no namespace prefix)
};t('homepage.hero.title')
// → "Welcome"t('aboutUs.hero.title')
// → "About Our Mission"t('header.navigation.home')
// → "Home" (no namespace)URL path → camelCase namespace (remove slashes and hyphens)
/about-us→aboutUs/contact-us→contactUs/resources/blog→resourcesBlog/→homepageHeader, footer, and other shared components live in src/components/_data/ and spread at root level → accessible everywhere without namespace prefix
src/components/_data/
├── en.json
├── es.json
├── fr.json
└── ...
// en.json
{
"header": {
"navigation": {
"home": "Home",
"about": "About",
"contact": "Contact"
}
},
"footer": {
"copyright": "© 2025..."
}
}// Load shared translations
const sharedMessages = (
await import(
`@/components/_data/${locale}.json`
)
).default;
const messages = {
homepage: ...,
aboutUs: ...,
...sharedMessages, // Spread at root
};Each page JSON includes a metadata section for SEO optimization
{
"metadata": {
"title": "About Us | Healthcare Platform",
"description": "Learn about our mission...",
"keywords": ["healthcare", "mission"]
},
"hero": {
"title": "About Our Mission"
}
}export async function generateMetadata({
params: { locale }
}) {
const t = await getTranslations(
'aboutUs.metadata'
);
return {
title: t('title'),
description: t('description'),
};
}Zero key collisions across all translation files
Only page-specific translations loaded per route
Clear ownership: each page owns its translations
Translation Pipeline
From spreadsheets to production with confidence-based QA
Translators work in Google Sheets, not JSON. We received spreadsheets with the following structure:
| Page | Section | Element | Type | English | Spanish | ... |
|---|---|---|---|---|---|---|
| Homepage | Hero | Headline | Static HTML | Welcome... | Bienvenido... | ... |
| Blog | Post 123 | Title | Dynamic Backend | How to... | Cómo... | ... |
| FAQ | Q5 | Text | TBD Post Launch | When... | TBD | ... |
"Headline" vs "Title", "Hero" vs "Hero Section", determining destination (JSON vs CMS) required intelligent processing.
Pipeline Flow
Translator input
Semantic mapping
With QA flags
Fix flagged items
Built a Bun script with semantic mapping and confidence levels to intelligently process translations
Clear mappings processed automatically
Reasonable guess, flagged for review
Ambiguous, requires manual work
Automated processing with clear indicators for manual review
{
"hero": {
"title": "About Us", // ✅ Auto-processed
"FIX_CHECK_subtitle": "Our mission", // ⚠️ Needs review
"FIX_NAMING_text1": "TODO_MISSING_ES", // ❌ Manual work needed
"description": "We help communities..." // ✅ Auto-processed
},
"stats": {
"FIX_CHECK_metric1": "TODO_MISSING_FR", // ⚠️ Missing translation
"metric2": "10,000+ patients served" // ✅ Complete
}
}# Search for all QA markers
grep -r "FIX_\|TODO_" src/app
# Results:
# src/app/[locale]/about/_data/es.json:
# "FIX_CHECK_subtitle": ...
# src/app/[locale]/blog/_data/fr.json:
# "TODO_MISSING_FR": ...# Next.js errors on missing keys
npm run build
# If build succeeds:
# ✅ All translations present
# ✅ No invalid JSON
# ✅ Safe to deployResults
Auto-processed with high confidence
Flagged for manual review and cleanup
Performance
100% static generation with zero runtime translation costs
Build Time
For ~1,800 pages (expected 10-15 min)
First Load
62% reduction from before namespacing
Translations/Page
Gzipped, per locale + namespace
TTFB
CDN edge cached
Size reduction achieved
Next.js automatically eliminates unused translations
Only current page's namespace loaded
Only current locale's translations loaded
User on /es/about-us downloads Spanish about-us only
All pages generated at build time with generateStaticParams
// app/[locale]/[...slug]/page.tsx
export async function generateStaticParams() {
const locales = ['en', 'es', 'fr', 'pt', 'sw', 'hi', 'tl', 'lg', 'yo'];
const pages = [
'about-us',
'contact-us',
'blog',
'faq',
// ... 38 pages total
];
// Generate all combinations
return locales.flatMap(locale =>
pages.map(page => ({
locale,
slug: page.split('/'),
}))
);
}
// Result: ~1,800 pages statically generated
// 9 locales × 38 pages × ~5 dynamic routes = 1,710+ pagesFor dynamic content from Payload CMS, optimized queries prevent performance bottlenecks
Prevent N+1 queries on related content
Only fetch required fields
DB-level filtering (indexed)
const posts = await payload.find({
collection: 'posts',
locale: params.locale, // Indexed filter
depth: 1, // Prevent N+1
select: { // Only needed fields
title: true,
slug: true,
publishedDate: true,
},
limit: 10,
});Runtime translation API calls
TMS subscription fees
Client-side translation loading
Lessons Learned
What worked, what didn't, and what surprised us
Added 2 languages in under 1 hour with zero config bugs
Modified one array in localization.ts. Both Next.js and Payload CMS synchronized automatically. No build errors, no deployment issues.
Zero key collisions across 290+ files
Every page owns its translations. Clear ownership made code reviews faster and prevented merge conflicts.
Files next to components for easy maintenance
page/_data/en.json next to page/page.tsx. When editing a page, all translations are immediately visible without hunting through folders.
Deployed with zero runtime translation errors
npm run build fails immediately if any translation key is missing. Caught errors before they reached production.
Translators understood context without code
hero.title and stats.patientsServed are self-documenting. Translators didn't need to ask what h1 or span_2 meant.
The Deliverable
~1,800 pages serving critical health information globally
Total Pages
JSON Files
Lines of Translations
Build Time
TTFB
Success Factors
One config file for all systems
Zero key collisions guaranteed
Translations next to components
Static JSON + CMS content
Zero runtime errors deployed
Graceful degradation built-in
All pages pre-generated
62% bundle size reduction
No API calls or TMS fees
Languages Supported
Key Takeaway
Enterprise-scale internationalization requires careful architectural decisions. Single source of truth, namespace isolation, and build-time validation proved essential for maintaining quality across 9 languages and ~1,800 pages.
Runtime translation errors
Static generation
To add new languages