Case StudyTechnology
Nov 4, 2025

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
Enterprise i18n at Scale preview

Executive Summary

Building a comprehensive internationalization system supporting 9 languages across ~1,800 pages using Next.js 16 and next-intl 4.4

Project Scale
Pages~1,800 pages
Page Templates38 templates
Languages9 languages
Translation Files290+ JSON files
Lines of Translations~270K lines
Supported Languages
EnglishSpanishFrenchPortugueseKiswahiliHindiTagalogLugandaYoruba
Technology Stack

Framework

Next.js 16React 19TypeScript 5

i18n

next-intl 4.4Payload CMS 3.62

Strategy

100% StaticEnglish Fallback
Performance Metrics
3m 42s

Build Time

89-102 KB

First Load

~4 KB

Translations/Page

< 50ms

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.

38 page templates + dynamic CMS content
Hybrid sources: static UI + dynamic CMS
Zero tolerance for errors (health information)

Architecture Overview

Core i18n Setup

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 framework
Next.js 16next-intl 4.4Payload CMSTypeScript
URL Structure

Locale-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                  → Yoruba

Example 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 templates

Request Flow

User Request

/es/about-us

Locale Detection

Extract locale from URL segment

Static Page

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

Single Configuration File

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"];
Single SourceType SafeAuto-sync
Benefits
Add Once, Works Everywhere

New languages propagate to Next.js and Payload automatically

Zero Config Drift

Impossible to have mismatched locale lists

Type Safety

TypeScript ensures valid locale codes across codebase

Real-World Impact
Added 2 Languages in Under 1 Hour

Modified one array in localization.ts. No build errors, no config debugging, no production issues.

Zero Deployment Bugs

Never had a mismatch between Next.js routing and CMS locale options across 9 languages.

Implementation Pattern

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

Two Content Types

Static JSON

Button labels and navigation
Marketing copy and hero text
SEO metadata
Form labels and validation

Payload CMS

Blog posts and articles
FAQs and help documentation
Legal documents and policies
User-generated content

Translation File Organization

Colocated translations next to page components for maintainability

src/
app/[locale]/
about-us/
_data/
en.json← Static UI translations
es.json
fr.json
...
_sections/
hero.tsx
our-story.tsx
page.tsx
blog/
_data/
en.json← UI chrome only
page.tsx← Fetches posts from Payload
components/_data/← Shared component translations
en.json
es.json
...

JSON Structure Patterns

Handling inline formatting without HTML in JSON

Most Common: Split into Parts
JSON
{
  "part1": "We provide",
  "bold": "support",
  "part2": "worldwide."
}
Component
<p>
  {t('part1')}{' '}
  <strong>{t('bold')}</strong>{' '}
  {t('part2')}
</p>
Translation Example
English

We provide support worldwide.

Spanish

Brindamos soporte en todo el mundo.

{ "part1": "Brindamos", "bold": "soporte", "part2": "en todo el mundo." }
Why Not HTML in JSON?
XSS security risk from unsanitized HTML
No TypeScript type safety for markup
React hydration mismatches
Translators can break HTML syntax
Why Not Regex?
Breaks with RTL languages (string indices ≠ visual order)
Splits combining characters in Hindi, Yoruba (invalid Unicode)
Word boundaries vary (spaces in English, none in Chinese)
Naming Conventions

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.

✓ Good: Semantic
title
description
label
buttonText
imageAlt
✗ Bad: HTML Tags
h1
paragraph
span
button
alt_text
Key Principle

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

Namespace Pattern

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)
};
Zero CollisionsTree-ShakenScalable
Usage in Components
Homepage
t('homepage.hero.title')
// → "Welcome"
About Page
t('aboutUs.hero.title')
// → "About Our Mission"
Shared Components
t('header.navigation.home')
// → "Home" (no namespace)
Naming Convention

URL path → camelCase namespace (remove slashes and hyphens)

/about-usaboutUs
/contact-uscontactUs
/resources/blogresourcesBlog
/homepage
Shared Components Strategy

Header, footer, and other shared components live in src/components/_data/ and spread at root level → accessible everywhere without namespace prefix

File Structure
src/components/_data/
├── en.json
├── es.json
├── fr.json
└── ...

// en.json
{
  "header": {
    "navigation": {
      "home": "Home",
      "about": "About",
      "contact": "Contact"
    }
  },
  "footer": {
    "copyright": "© 2025..."
  }
}
Loading Pattern
// Load shared translations
const sharedMessages = (
  await import(
    `@/components/_data/${locale}.json`
  )
).default;

const messages = {
  homepage: ...,
  aboutUs: ...,
  ...sharedMessages, // Spread at root
};
SEO Metadata Pattern

Each page JSON includes a metadata section for SEO optimization

Translation File (about-us/en.json)
{
  "metadata": {
    "title": "About Us | Healthcare Platform",
    "description": "Learn about our mission...",
    "keywords": ["healthcare", "mission"]
  },
  "hero": {
    "title": "About Our Mission"
  }
}
Component (generateMetadata)
export async function generateMetadata({
  params: { locale }
}) {
  const t = await getTranslations(
    'aboutUs.metadata'
  );

  return {
    title: t('title'),
    description: t('description'),
  };
}
290+ Files

Zero key collisions across all translation files

Tree-Shaken

Only page-specific translations loaded per route

Maintainable

Clear ownership: each page owns its translations

Translation Pipeline

From spreadsheets to production with confidence-based QA

The Translator Workflow

Translators work in Google Sheets, not JSON. We received spreadsheets with the following structure:

PageSectionElementTypeEnglishSpanish...
HomepageHeroHeadlineStatic HTMLWelcome...Bienvenido......
BlogPost 123TitleDynamic BackendHow to...Cómo......
FAQQ5TextTBD Post LaunchWhen...TBD...
Challenge: Inconsistent Naming

"Headline" vs "Title", "Hero" vs "Hero Section", determining destination (JSON vs CMS) required intelligent processing.

Pipeline Flow

Google Sheets

Translator input

Bun Script

Semantic mapping

JSON Files

With QA flags

Manual Review

Fix flagged items

Confidence-Based Processing

Built a Bun script with semantic mapping and confidence levels to intelligently process translations

High Confidence

Clear mappings processed automatically

"H1" → title
"Hero Section" → hero
"Button Text" → buttonText
Medium Confidence

Reasonable guess, flagged for review

FIX_CHECK_keyName
Low Confidence

Ambiguous, requires manual work

FIX_NAMING_keyName
Example Output with QA Markers

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
  }
}
AutomatedFlaggedReviewable
Quality Assurance
Find Items for Review
# 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": ...
Build-Time Validation
Final Verification
# Next.js errors on missing keys
npm run build

# If build succeeds:
# ✅ All translations present
# ✅ No invalid JSON
# ✅ Safe to deploy

Results

80%

Auto-processed with high confidence

20%

Flagged for manual review and cleanup

Performance

100% static generation with zero runtime translation costs

Key Metrics
3m 42s

Build Time

For ~1,800 pages (expected 10-15 min)

89-102 KB

First Load

62% reduction from before namespacing

~4 KB

Translations/Page

Gzipped, per locale + namespace

< 50ms

TTFB

CDN edge cached

Bundle Size Breakdown
Before Namespacing262 KB
After Namespacing102 KB
62%

Size reduction achieved

Tree-Shaking Impact

Next.js automatically eliminates unused translations

Per Route

Only current page's namespace loaded

Per Locale

Only current locale's translations loaded

Result

User on /es/about-us downloads Spanish about-us only

Static Generation Pattern

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+ pages
100% StaticBuild TimeZero Runtime Cost
CMS Query Optimization

For dynamic content from Payload CMS, optimized queries prevent performance bottlenecks

depth: 1

Prevent N+1 queries on related content

select

Only fetch required fields

locale filter

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,
});
0

Runtime translation API calls

0

TMS subscription fees

0

Client-side translation loading

Lessons Learned

What worked, what didn't, and what surprised us

Single Source of Truth

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.

Namespace Per Page

Zero key collisions across 290+ files

Every page owns its translations. Clear ownership made code reviews faster and prevented merge conflicts.

Colocated Translations

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.

Build-Time Validation

Deployed with zero runtime translation errors

npm run build fails immediately if any translation key is missing. Caught errors before they reached production.

Semantic Naming

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

Final Metrics
~1,800

Total Pages

290+

JSON Files

~270K

Lines of Translations

3m 42s

Build Time

< 50ms

TTFB

Success Factors

Architecture
1
Single Source of Truth

One config file for all systems

2
Namespace Architecture

Zero key collisions guaranteed

3
Colocated Files

Translations next to components

Strategy
1
Hybrid Approach

Static JSON + CMS content

2
Build-Time Validation

Zero runtime errors deployed

3
English Fallback

Graceful degradation built-in

Performance
1
100% Static

All pages pre-generated

2
Tree-Shaking

62% bundle size reduction

3
Zero Runtime Cost

No API calls or TMS fees

Technology Stack
Framework
Next.js 16React 19TypeScript 5
Internationalization
next-intl 4.4Payload CMS 3.62
Infrastructure
VercelEdge CDNPostgres

Languages Supported

EnglishSpanishFrenchPortugueseKiswahiliHindiTagalogLugandaYoruba

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.

Zero

Runtime translation errors

100%

Static generation

< 1 hour

To add new languages

Explore More Work

Discover our other projects, case studies, and insights

View Full Portfolio →