Front-end Engineering Lab
PatternsInternationalization

i18n Architecture

How to structure internationalization in large-scale applications

i18n Architecture

Proper i18n architecture is crucial for maintainability as your application grows. This covers file organization, namespace strategies, and translation management workflows.

File Organization Strategies

1. Flat Structure (Small Apps)

messages/
├── en.json
├── pt.json
├── es.json
└── ar.json

Pros: Simple, easy to start
Cons: Hard to maintain as app grows

messages/
├── en/
│   ├── common.json
│   ├── auth.json
│   ├── dashboard.json
│   ├── products.json
│   └── checkout.json
├── pt/
│   ├── common.json
│   ├── auth.json
│   ├── dashboard.json
│   ├── products.json
│   └── checkout.json
└── ar/
    ├── common.json
    ├── auth.json
    ├── dashboard.json
    ├── products.json
    └── checkout.json

Pros: Scalable, clear ownership, code splitting
Cons: More files to manage

3. Nested by Feature + Component

messages/
├── en/
│   ├── common/
│   │   ├── buttons.json
│   │   ├── forms.json
│   │   └── navigation.json
│   ├── features/
│   │   ├── auth/
│   │   │   ├── login.json
│   │   │   └── signup.json
│   │   └── dashboard/
│   │       ├── overview.json
│   │       └── settings.json
│   └── pages/
│       ├── home.json
│       └── about.json

Pros: Very organized, matches code structure
Cons: Complex, potential over-engineering

Translation File Patterns

Flat Keys (Simple)

{
  "welcomeMessage": "Welcome to our app",
  "loginButton": "Sign In",
  "logoutButton": "Sign Out",
  "userProfileTitle": "User Profile"
}

Pros: Simple, easy to search
Cons: Hard to organize, name collisions

{
  "auth": {
    "login": {
      "title": "Sign In",
      "email": "Email Address",
      "password": "Password",
      "button": "Sign In",
      "forgotPassword": "Forgot Password?",
      "noAccount": "Don't have an account?",
      "signUpLink": "Sign up now"
    },
    "signup": {
      "title": "Create Account",
      "button": "Sign Up"
    }
  },
  "dashboard": {
    "welcome": "Welcome, {name}",
    "stats": {
      "users": "Total Users",
      "revenue": "Revenue",
      "growth": "Growth"
    }
  }
}

Pros: Organized, scalable, clear context
Cons: Deeper nesting

Namespace Strategy

1. Global Namespace

// One namespace for everything
const t = useTranslations();
t('auth.login.title');
t('dashboard.welcome');
// Separate namespaces per feature
const tAuth = useTranslations('auth');
const tDashboard = useTranslations('dashboard');

tAuth('login.title');
tDashboard('welcome');

3. Component-Scoped

// Each component has its namespace
function LoginForm() {
  const t = useTranslations('auth.login');
  
  return (
    <form>
      <h1>{t('title')}</h1>
      <input placeholder={t('email')} />
      <button>{t('button')}</button>
    </form>
  );
}

Loading Strategies

1. Load All Upfront

// middleware.ts
export default createMiddleware({
  locales: ['en', 'pt', 'es'],
  defaultLocale: 'en',
});

// i18n.ts
export default getRequestConfig(async ({ locale }) => ({
  // Load all messages at once
  messages: (await import(`./messages/${locale}.json`)).default,
}));

Pros: Simple, no loading states
Cons: Large initial bundle

// i18n.ts
export default getRequestConfig(async ({ locale }) => ({
  messages: {
    // Always load common
    ...((await import(`./messages/${locale}/common.json`)).default),
    
    // Load route-specific
    ...(typeof window !== 'undefined' && window.location.pathname.includes('/dashboard')
      ? (await import(`./messages/${locale}/dashboard.json`)).default
      : {}),
  },
}));

3. Dynamic Import in Component

'use client';

import { useState, useEffect } from 'react';

function DashboardPage() {
  const [messages, setMessages] = useState(null);
  const locale = useLocale();

  useEffect(() => {
    import(`@/messages/${locale}/dashboard.json`)
      .then(module => setMessages(module.default));
  }, [locale]);

  if (!messages) return <Loading />;

  return <Dashboard messages={messages} />;
}

Translation Management Workflows

1. Manual Process

1. Developer adds key to en.json
2. Creates PR
3. Translator translates to other languages
4. PR merged

Pros: Simple, no tools needed
Cons: Slow, error-prone, doesn't scale

2. Automated with CI/CD

# .github/workflows/i18n.yml
name: Translation Sync

on:
  push:
    paths:
      - 'messages/en/**'

jobs:
  sync:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Extract new keys
        run: npm run i18n:extract
      
      - name: Sync to translation service
        run: npm run i18n:upload
        env:
          TRANSLATION_API_KEY: ${{ secrets.TRANSLATION_API_KEY }}
      
      - name: Create PR for new translations
        uses: peter-evans/create-pull-request@v5
        with:
          title: 'chore: sync translations'
          body: 'Automated translation sync'

3. Translation Platform Integration

Popular Platforms:

  • Lokalise: Full-featured, $$$
  • Crowdin: Good for open source
  • Phrase: Enterprise-focused
  • Transifex: Mature, widely used
  • Weblate: Open source, self-hosted

Type-Safe Translations

Generate Types from Translations

// scripts/generate-i18n-types.ts
import { writeFileSync } from 'fs';
import en from '../messages/en.json';

function generateTypes(obj: any, prefix = ''): string[] {
  const keys: string[] = [];
  
  for (const [key, value] of Object.entries(obj)) {
    const fullKey = prefix ? `${prefix}.${key}` : key;
    
    if (typeof value === 'object' && value !== null) {
      keys.push(...generateTypes(value, fullKey));
    } else {
      keys.push(`"${fullKey}"`);
    }
  }
  
  return keys;
}

const keys = generateTypes(en);

const types = `
// Auto-generated - do not edit
export type TranslationKey = ${keys.join(' | ')};
`;

writeFileSync('types/i18n.ts', types);

Usage

import type { TranslationKey } from '@/types/i18n';

function useTypedTranslation() {
  const t = useTranslations();
  
  return (key: TranslationKey) => t(key);
}

// Usage
const tTyped = useTypedTranslation();
tTyped('auth.login.title'); // ✅ Valid
tTyped('invalid.key'); // ❌ TypeScript error

Versioning Translations

Git-Based Versioning

messages/
├── v1/
│   ├── en.json
│   └── pt.json
├── v2/
│   ├── en.json
│   └── pt.json
└── current -> v2/

Feature Flags for Translations

const t = useTranslations();

// Use new translation if feature flag enabled
const message = featureFlags.newCopy 
  ? t('auth.login.titleV2')
  : t('auth.login.title');

Translation Coverage Reports

// scripts/translation-coverage.ts
import en from '../messages/en.json';
import pt from '../messages/pt.json';
import es from '../messages/es.json';

function flattenKeys(obj: any, prefix = ''): string[] {
  const keys: string[] = [];
  
  for (const [key, value] of Object.entries(obj)) {
    const fullKey = prefix ? `${prefix}.${key}` : key;
    
    if (typeof value === 'object' && value !== null) {
      keys.push(...flattenKeys(value, fullKey));
    } else {
      keys.push(fullKey);
    }
  }
  
  return keys;
}

const enKeys = new Set(flattenKeys(en));
const ptKeys = new Set(flattenKeys(pt));
const esKeys = new Set(flattenKeys(es));

console.log('\nTranslation Coverage:');
console.log(`English (base): ${enKeys.size} keys`);
console.log(`Portuguese: ${ptKeys.size}/${enKeys.size} (${(ptKeys.size / enKeys.size * 100).toFixed(1)}%)`);
console.log(`Spanish: ${esKeys.size}/${enKeys.size} (${esKeys.size / enKeys.size * 100).toFixed(1)}%)`);

// Find missing keys
const missingPt = [...enKeys].filter(k => !ptKeys.has(k));
const missingEs = [...enKeys].filter(k => !esKeys.has(k));

if (missingPt.length > 0) {
  console.log('\nMissing in Portuguese:');
  missingPt.forEach(k => console.log(`  - ${k}`));
}

if (missingEs.length > 0) {
  console.log('\nMissing in Spanish:');
  missingEs.forEach(k => console.log(`  - ${k}`));
}

Context for Translators

{
  "auth": {
    "login": {
      "_comment": "Login page translations",
      "title": "Sign In",
      "title@context": "Main heading on login page",
      
      "button": "Sign In",
      "button@context": "Primary action button, max 15 characters",
      "button@maxLength": 15
    }
  }
}

Best Practices

  1. Use Nested Structure: Organize by feature/page
  2. Namespace Everything: Avoid global namespace pollution
  3. Lazy Load: Split translations by route
  4. Automate: Use CI/CD for translation sync
  5. Type Safety: Generate types from translations
  6. Track Coverage: Monitor translation completeness
  7. Add Context: Help translators understand usage
  8. Version Control: Track translation changes
  9. Fallback Strategy: Handle missing translations
  10. Review Process: QA translations before deployment

Common Pitfalls

No organization: All keys in one flat file
Organize by feature/namespace

Loading everything: Large bundle size
Lazy load by route

No type safety: Runtime errors
Generate types from translations

No coverage tracking: Missing translations in production
Automated coverage reports

No context for translators: Poor translations
Add comments and context

Scaling Considerations

App SizeStrategyTools
SmallFlat files, manualJSON files
MediumNested, semi-automatednext-intl + Git
LargePlatform, automatedLokalise/Crowdin + CI/CD
EnterprisePlatform, workflows, QAFull translation platform

Good i18n architecture scales with your application and makes translation a manageable, automated process.

On this page