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.jsonPros: Simple, easy to start
Cons: Hard to maintain as app grows
2. Nested by Feature (Recommended)
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.jsonPros: 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.jsonPros: 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
Nested Keys (Recommended)
{
"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');2. Feature Namespaces (Recommended)
// 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
2. Lazy Load by Route (Recommended)
// 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 mergedPros: 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 errorVersioning 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
- Use Nested Structure: Organize by feature/page
- Namespace Everything: Avoid global namespace pollution
- Lazy Load: Split translations by route
- Automate: Use CI/CD for translation sync
- Type Safety: Generate types from translations
- Track Coverage: Monitor translation completeness
- Add Context: Help translators understand usage
- Version Control: Track translation changes
- Fallback Strategy: Handle missing translations
- 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 Size | Strategy | Tools |
|---|---|---|
| Small | Flat files, manual | JSON files |
| Medium | Nested, semi-automated | next-intl + Git |
| Large | Platform, automated | Lokalise/Crowdin + CI/CD |
| Enterprise | Platform, workflows, QA | Full translation platform |
Good i18n architecture scales with your application and makes translation a manageable, automated process.