Centralised Locale System
This application uses a centralised internationalisation system powered by i18next for managing all translatable text. The system provides type-safe, globally accessible locale data across the entire application with support for namespaces, interpolation, and graceful fallbacks.
Architecture
1. Locale Files (/locales/
)
All translatable text is stored in JSON files in the locales/
directory:
locales/
├── en.json
2. i18n Loader (src/scripts/helpers/i18nLoader.ts
)
A robust i18next-based loader that:
- ✅ Synchronously initialises i18next for immediate availability
- ✅ Supports namespace-based organization
- ✅ Provides interpolation with
{variable}
syntax - ✅ Handles missing keys gracefully with fallbacks
- ✅ Includes comprehensive error handling and logging
- ✅ Exports type-safe wrapper functions
3. Express Middleware (middleware/setupLocale.ts
)
Injects locale functions into the Express request/response cycle:
- Makes locale functions available in
res.locals
for templates - Adds
req.locale
object for controller access - Provides
ExpressLocaleLoader
interface for type safety
4. Nunjucks Integration (utils/nunjucksSetup.ts
)
Makes locale functions globally available in all Nunjucks templates via the nunjucksT
function.
Usage
In TypeScript Controllers
import { t } from '#src/scripts/helpers/index.js';
// Use t() for all translations with namespace support
const backButton = t('common.back');
const pageTitle = t('pages.caseDetails.title');
// Interpolation support
const welcomeMessage = t('messages.welcome', { name: 'John' });
const errorMessage = t('errors.http.default', { status: '404' });
// In Express controllers via req.locale
export function myController(req: Request, res: Response): void {
const errorMsg = req.locale.t('errors.general.caseNotFound');
const welcomeMsg = req.locale.t('messages.welcome', { name: user.name });
}
In Nunjucks Templates
Locale functions are globally available through the t
function:
{# Standard translation calls #}
<h1>{{ t('pages.home.title') }}</h1>
<button>{{ t('common.save') }}</button>
<a href="/">{{ t('common.back') }}</a>
{# Interpolation with variables #}
<p>{{ t('messages.welcome', { name: user.name }) }}</p>
<title>{{ t('pages.yourCases.title', { serviceName: config.SERVICE_NAME }) }}</title>
{# GOV.UK component integration #}
{{ govukInput({
label: { text: t('forms.clientDetails.name.label') },
hint: { text: t('forms.clientDetails.name.hint') }
}) }}
{# Conditional rendering #}
<p class="govuk-error-message">{{ t('errors.validation.required') }}</p>
Locale File Structure
The locale files follow a nested namespace structure organised by functional areas e.g:
{
"common": {
"back": "Back",
"save": "Save",
"cancel": "Cancel",
"yes": "Yes",
"no": "No"
},
"pages": {
"home": {
"title": "Manage your civil cases"
},
"caseDetails": {
"title": "Case details – {serviceName} – GOV.UK",
"tabs": {
"clientDetails": "Client details"
}
}
},
"forms": {
"clientDetails": {
"name": {
"label": "Client name",
"hint": "Enter the full legal name"
},
"address": {
"validationError": {
"notChanged": "You must change the address before saving"
}
}
}
},
"errors": {
"http": {
"400": "Invalid request. Please check your input and try again.",
"404": "Page not found",
"default": "Service error ({status}). Please try again later."
},
"validation": {
"required": "This field is required",
"invalidFormat": "Please enter a valid {fieldType}"
}
}
}
Key Naming Conventions
- Namespace organization: Top-level keys represent functional areas (
common
,pages
,forms
,errors
) - Hierarchical structure: Use dot notation for nested access (
pages.caseDetails.title
) - Descriptive naming: Keys should clearly indicate their purpose and context
- Consistent patterns: Use standard suffixes like
label
,hint
,error
,title
- Validation grouping: Group validation messages under
validationError
objects
Interpolation Support
The system uses i18next’s interpolation with {variable}
syntax:
{
"messages": {
"welcome": "Welcome, {name}!",
"itemCount": "You have {count} items"
},
"pages": {
"title": "{pageTitle} – {serviceName} – GOV.UK"
}
}
Usage:
// Simple interpolation
t('messages.welcome', { name: 'John' });
// Result: "Welcome, John!"
// Multiple variables
t('pages.title', { pageTitle: 'Case Details', serviceName: 'LAA SiLAS' });
// Result: "Case Details – LAA SiLAS – GOV.UK"
// In templates
{{ t('messages.itemCount', { count: cases.length }) }}
System Architecture Details
Initialization Process
- Synchronous Loading:
initializeI18nextSync()
runs during app startup - Resource Loading: Reads
locales/en.json
using Node.jsfs.readFileSync
- i18next Configuration: Sets up namespaces, interpolation, and fallbacks
- Global Availability: Functions become immediately available across the app
Error Handling
- Missing Files: Falls back to empty resources with console warning
- Invalid JSON: Gracefully handles parse errors
- Missing Keys: Returns the key path as fallback text
- Development Mode: Provides detailed logging for debugging
Performance Considerations
- Synchronous initialization ensures translations are available immediately
- In-memory caching via i18next for fast runtime access
- Single file loading keeps startup time minimal
- No network requests - all resources are local
Key Features
🚀 i18next Integration
- Built on the industry-standard i18next library
- Full support for namespaces and interpolation
- Comprehensive error handling and fallbacks
- Development-friendly logging and debugging
🌐 Global Availability
- Functions available in all Nunjucks templates via global
t()
- Express middleware injects locale into
req.locale
andres.locals
- TypeScript modules can import and use directly
- Consistent API across all application layers
⚡ Performance Optimised
- Synchronous initialization prevents race conditions
- File-based loading with caching for optimal performance
- Minimal runtime overhead with direct function calls
- No external network dependencies
🛡️ Type Safety & Reliability
- Full TypeScript interface definitions
ExpressLocaleLoader
interface for consistent typing- Graceful degradation when keys are missing
- Comprehensive error boundaries and fallbacks
🔧 Developer Experience
- Simple
t('namespace.key')
syntax - Intuitive interpolation:
t('key', { variable: 'value' })
- Clear error messages in development mode
- Hot reload support during development
- Comprehensive test coverage
Best Practices
- Use descriptive namespace paths:
forms.clientDetails.name.label
- Leverage interpolation for dynamic content:
t('welcome', { name })
- Group related translations in logical namespace hierarchies
- Test interpolation variables to ensure proper substitution
- Keep translation keys focused and context-specific
- Validate locale changes across all usage points
- Follow consistent naming patterns throughout the locale file
Troubleshooting
Common Issues
// ❌ Wrong: Missing namespace or incorrect key
const text = t('back'); // Should be t('common.back')
// ✅ Correct: Full namespace path
const text = t('common.back');
// ❌ Wrong: Incorrect interpolation syntax
const msg = t('welcome', { user: 'John' }); // Variable name doesn't match
// ✅ Correct: Match the variable names in locale file
const msg = t('messages.welcome', { name: 'John' });
Validation and Testing
// Check if translations work as expected
console.log(t('common.back')); // Should output: "Back"
// Test interpolation
console.log(t('messages.welcome', { name: 'Test' })); // Should output: "Welcome, Test!"
Development vs Production
- Development: Missing keys and errors logged to console with warnings
- Production: Missing keys return the key path silently for graceful degradation
- Testing: Use
initialiseI18nextSync()
in test setup for consistent state
Migration Guide
If migrating from a previous locale system:
- Update import statements to use the new i18nLoader functions
- Replace object notation
t.common.back
with function callst('common.back')
- Update interpolation syntax to use the standard
{variable}
format - Add namespace prefixes to all translation keys
- Test all templates and controllers to ensure proper function calls