. Store it as an environment variable scoped to the build environment.
FormatJS provides the runtime engine for ICU message syntax, pluralization, and rich text formatting. We wrap the application in a locale context that dynamically loads JSON assets based on the active language.
// src/i18n/config.ts
import { createIntl, createIntlCache, IntlShape } from '@formatjs/intl';
const cache = createIntlCache();
let currentIntl: IntlShape | null = null;
export async function loadLocaleMessages(locale: string): Promise<Record<string, string>> {
const response = await fetch(`/locales/${locale}.json`);
if (!response.ok) throw new Error(`Failed to load locale: ${locale}`);
return response.json();
}
export function initializeIntl(locale: string, messages: Record<string, string>): IntlShape {
currentIntl = createIntl({ locale, messages }, cache);
return currentIntl;
}
export function getIntl(): IntlShape {
if (!currentIntl) throw new Error('Intl not initialized. Call initializeIntl first.');
return currentIntl;
}
Step 3: Implement the Locale Provider
The provider manages locale state, handles dynamic imports, and exposes the formatting API to the component tree.
// src/i18n/LocaleProvider.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';
import { loadLocaleMessages, initializeIntl } from './config';
interface LocaleContextValue {
locale: string;
setLocale: (lang: string) => void;
formatMessage: (descriptor: { id: string; defaultMessage?: string }) => string;
}
const LocaleContext = createContext<LocaleContextValue | undefined>(undefined);
export function LocaleProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<string>(() => navigator.language.slice(0, 2) || 'en');
const [intl, setIntl] = useState<ReturnType<typeof initializeIntl> | null>(null);
useEffect(() => {
loadLocaleMessages(locale).then((messages) => {
setIntl(initializeIntl(locale, messages));
});
}, [locale]);
const setLocale = (lang: string) => setLocaleState(lang);
const formatMessage = (descriptor: { id: string; defaultMessage?: string }) => {
if (!intl) return descriptor.defaultMessage || descriptor.id;
return intl.formatMessage(descriptor);
};
return (
<LocaleContext.Provider value={{ locale, setLocale, formatMessage }}>
{children}
</LocaleContext.Provider>
);
}
export function useLocale() {
const ctx = useContext(LocaleContext);
if (!ctx) throw new Error('useLocale must be used within LocaleProvider');
return ctx;
}
Step 4: Wire the CLI and Extraction Scripts
The i18.dev CLI scans the codebase for formatMessage calls, extracts message IDs, and pushes them to the dashboard. We configure this via package.json scripts.
{
"scripts": {
"i18n:extract": "i18dev extract --src ./src --out ./locales/en.json",
"i18n:push": "i18dev push --token $I18DEV_PAT --project quick-start-project",
"i18n:sync": "npm run i18n:extract && npm run i18n:push"
}
}
Step 5: Orchestrate via AI Prompts
In Lovable, the workflow is triggered through structured prompts. The first prompt initializes the infrastructure:
"Install @formatjs/intl, configure a LocaleProvider with dynamic JSON loading, and add a language switch component in the header. Set up i18.dev CLI scripts for extraction and push."
The second prompt handles string migration:
"Scan the DashboardPage component. Replace all hardcoded text with formatMessage calls using descriptive IDs. Run the extraction script and push new strings to the i18.dev dashboard."
When a new language is added in the dashboard, i18.dev generates a context-aware prompt that instructs Lovable to fetch the translated JSON, register the locale in the switcher, and update routing logic. This eliminates manual prompt engineering and ensures consistency across iterations.
Architecture Rationale:
- FormatJS over custom formatters: ICU syntax handles pluralization, gender, and rich text natively. Building this from scratch introduces edge-case bugs.
- CLI-driven extraction: AST parsing guarantees 100% coverage of translatable strings, unlike regex-based search-and-replace.
- Prompt orchestration: AI code generators excel at scaffolding but struggle with stateful workflows. Prompts act as deterministic triggers that bridge generation and configuration.
Pitfall Guide
1. Hardcoded Strings in Conditional Rendering
Explanation: Developers often wrap static text in formatMessage but leave strings inside ternary operators or map functions unextracted. The CLI skips these, causing missing translations in production.
Fix: Always extract strings before conditional evaluation. Use explicit message IDs and pass variables as parameters to formatMessage.
2. Ignoring ICU Pluralization Syntax
Explanation: Direct string concatenation for counts (${count} items) breaks in languages with complex plural rules (e.g., Russian, Arabic). FormatJS requires explicit plural syntax.
Fix: Use {count, plural, one {# item} other {# items}} syntax. Let the CLI validate ICU compliance during extraction.
3. Exposing PAT in Client Bundles
Explanation: Storing the i18.dev PAT in a client-accessible environment variable (e.g., VITE_I18DEV_PAT) leaks credentials. The token should only authenticate build-time CLI operations.
Fix: Prefix build-only variables with I18DEV_PAT (no framework-specific prefix). Inject via CI/CD secrets and restrict usage to npm run i18n:push.
4. Ambiguous AI Prompt Scoping
Explanation: Vague prompts like "translate the app" cause the AI to overwrite logic, miss nested components, or generate conflicting locale configurations.
Fix: Scope prompts to specific files or components. Provide explicit instructions: "Extract strings from src/components/Checkout.tsx, wrap with formatMessage, and run extraction."
5. Skipping RTL and Layout Validation
Explanation: Adding Arabic or Hebrew without testing CSS logical properties causes overflow, misaligned icons, and broken grids. AI translation doesn't account for layout direction.
Fix: Use margin-inline-start instead of margin-left, text-align: start, and flexbox row-reverse for RTL. Test with dir="rtl" on the root element early.
6. Caching Stale Locale Files
Explanation: Browsers cache /locales/en.json aggressively. When new strings are added, users see fallback IDs or missing translations until cache expires.
Fix: Implement content-hash versioning: /locales/en.v1.json. Update the fetch URL dynamically based on a build manifest or query parameter.
7. Over-Reliance on AI Translation Without QA
Explanation: AI drafts are fast but lack domain context. Technical terms, brand voice, and regulatory phrasing often require human refinement.
Fix: Use i18.dev's review workflow. Assign linguistic reviewers to critical paths (checkout, legal, onboarding). Treat AI output as a first draft, not a final release.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Early-stage prototype | AI-first translation + CLI extraction | Speed outweighs linguistic precision; rapid iteration needed | Low (free tier, minimal review) |
| Production SaaS with EU users | AI draft + mandatory human QA + RTL testing | GDPR compliance, brand consistency, and layout stability required | Medium (reviewer hours, testing overhead) |
| High-traffic e-commerce | Build-time locale bundling + CDN caching | Runtime fetches add latency; precompiled messages improve TTFB | High (build pipeline complexity, CDN costs) |
| Internal tooling | Manual JSON sync + FormatJS runtime | Low string volume; prompt overhead not justified | Low (developer time only) |
Configuration Template
// src/i18n/manager.ts
import { createIntl, createIntlCache, IntlShape } from '@formatjs/intl';
const cache = createIntlCache();
const registry = new Map<string, Record<string, string>>();
export async function registerLocale(locale: string): Promise<IntlShape> {
if (registry.has(locale)) {
return createIntl({ locale, messages: registry.get(locale)! }, cache);
}
const res = await fetch(`/locales/${locale}.json`);
const messages = await res.json();
registry.set(locale, messages);
return createIntl({ locale, messages }, cache);
}
export function format(id: string, values?: Record<string, unknown>, fallback?: string): string {
try {
const intl = createIntl({ locale: 'en', messages: {} }, cache);
return intl.formatMessage({ id, defaultMessage: fallback || id }, values);
} catch {
return fallback || id;
}
}
// package.json (scripts section)
{
"scripts": {
"i18n:scan": "i18dev extract --src ./src --format json --out ./locales/source.json",
"i18n:upload": "i18dev push --token $I18DEV_PAT --project quick-start-project --file ./locales/source.json",
"i18n:download": "i18dev pull --token $I18DEV_PAT --project quick-start-project --out ./locales",
"i18n:sync": "npm run i18n:scan && npm run i18n:upload",
"i18n:refresh": "npm run i18n:download && npm run build"
}
}
# .env.example
I18DEV_PAT=your_personal_access_token_here
I18DEV_PROJECT_ID=quick-start-project
Quick Start Guide
- Initialize the pipeline: Create an i18.dev project, generate a PAT, and add it to your environment variables. Install
@formatjs/intl and configure the LocaleProvider wrapper.
- Extract and sync: Run the extraction script to parse your codebase. Push the generated JSON to the i18.dev dashboard using the CLI.
- Add languages: Use the dashboard to add target locales. AI translation drafts will populate instantly. Review and approve critical strings.
- Integrate into UI: Paste the dashboard-generated prompt into Lovable to wire the new locale JSON, update the language switcher, and configure routing. Validate with RTL and pluralization tests before deployment.