Enhance makeI18n to support bundled resources as an alternative to API mode:
- Add BundledResources type for locale → namespace → translations structure
- Make apiUrl optional when resources are provided
- Add BundledResourcesProvider for instant loading (no React Query needed)
- Update useI18n() to accept optional namespace parameter
- Add mergeNamespaces() helper for combining multiple namespaces
- Export BundledResources type from package index
- Add makeI18nResources export to landing app locales
- Update MAKEI18N_README.md with bundled resources documentation
This enables single-provider architecture with dot notation access:
const i18n = useI18n('common')
return <h1>{i18n.navigation.home}</h1>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
18 KiB
makeI18n Factory Architecture
Production-ready i18n system with domain/app isolation, bundled resources, and SSR support.
Table of Contents
- Overview
- Architecture
- Quick Start
- API Reference
- Advanced Usage
- SSR Support
- Performance
- Migration Guide
Overview
The makeI18n factory creates isolated i18n contexts per domain/app with:
- Factory Pattern: Each app gets its own
I18nProvideranduseI18nhook - Bundled Resources: Instant loading from bundled JSON (recommended for development)
- API Mode: Dynamic translations via API with React Query caching
- Auto-detection: Language resolution from URL → localStorage → browser → default
- Proxy Pattern: Dot notation access (
i18n.hero.title) with type safety - SSR Ready: Zero client-side fetching for initial render
- Route-aware: Automatically loads translations based on current route
Two Modes of Operation
| Mode | Best For | Loading | Network |
|---|---|---|---|
| Bundled Resources | Development, static sites | Instant (0ms) | None |
| API Mode | ML translation, dynamic content | Async | API call |
Why Factory Pattern?
// Problem: Global i18n instance shared across apps
import { useTranslation } from 'react-i18next';
function LandingPage() {
const { t } = useTranslation('landing-home'); // Manual namespace management
return <h1>{t('hero.title')}</h1>; // String-based keys, no autocomplete
}
// Solution: Domain/app-scoped instances
const { useI18n } = makeI18n('landing', 'home');
function LandingPage() {
const i18n = useI18n(); // Automatically uses landing/home translations
return <h1>{i18n.hero.title}</h1>; // Dot notation, IDE autocomplete
}
Architecture
Request Flow
┌─────────────────┐
│ Component │
│ const i18n = │
│ useI18n() │
└────────┬────────┘
│
│ Access: i18n.hero.title
▼
┌─────────────────┐
│ Proxy Object │ ◄─── Dot notation access
│ (created by │ Returns string or nested proxy
│ makeI18n) │
└────────┬────────┘
│
│ Query: ['i18n', domain, app, route, locale]
▼
┌─────────────────┐
│ React Query │ ◄─── Caching, deduplication, refetching
│ QueryClient │ Stale time: Infinity (translations rarely change)
└────────┬────────┘
│
│ Fetch if not cached
▼
┌─────────────────┐
│ API Endpoint │
│ /api/i18n/ │
│ {locale}/ │ ◄─── GET /api/i18n/en/landing/home/about
│ {domain}/ │ Returns JSON: { hero: { title: "..." } }
│ {app}{route} │
└─────────────────┘
Key Components
| Component | Purpose |
|---|---|
makeI18n(domain, app) |
Factory that creates isolated I18nProvider and useI18n |
I18nProvider |
React context provider for domain/app |
useI18n() |
Hook that returns proxy object for dot notation |
useI18nContext() |
Access locale, changeLocale, route, etc. |
createTranslationProxy() |
Creates nested proxy for dot notation access |
detectLanguage() |
Priority: URL param → localStorage → browser → default |
prefetchRoute() |
Preload translations for a route |
Quick Start
1. Create Domain/App Instance
// src/apps/landing/i18n.ts
import { makeI18n } from '@lilith/i18n';
export const { I18nProvider, useI18n, useI18nContext } = makeI18n('landing', 'app');
2. Prepare Bundled Resources
// src/apps/landing/locales/index.ts
import type { BundledResources } from '@lilith/i18n';
import commonEn from './en/common.json';
import landingEn from './en/landing.json';
export const resources: BundledResources = {
en: {
common: commonEn,
landing: landingEn,
},
// Additional locales loaded via API if needed
};
3. Wrap App with Provider (Bundled Mode - Recommended)
// src/apps/landing/App.tsx
import { I18nProvider } from './i18n.js';
import { resources } from './locales';
function App() {
return (
<I18nProvider resources={resources}>
<BrowserRouter>
<Routes />
</BrowserRouter>
</I18nProvider>
);
}
3b. Alternative: API Mode (for ML/dynamic translations)
// src/apps/landing/App.tsx
import { I18nProvider } from './i18n.js';
function App() {
return (
<I18nProvider apiUrl="/api/i18n">
<BrowserRouter>
<Routes />
</BrowserRouter>
</I18nProvider>
);
}
4. Use Translations in Components
// src/apps/landing/HomePage.tsx
import { useI18n } from './i18n.js';
function HomePage() {
// Use 'common' namespace (default) or specify: useI18n('landing')
const i18n = useI18n();
return (
<div>
<h1>{i18n.hero.title}</h1>
<p>{i18n.hero.subtitle}</p>
<button>{i18n.cta.button}</button>
{/* Deeply nested works too */}
<section>
{i18n.features.list.items.map((item, idx) => (
<div key={idx}>{item}</div>
))}
</section>
</div>
);
}
5. Add Language Switcher
import { useI18nContext } from './i18n.js';
function LanguageSwitcher() {
const { locale, changeLocale } = useI18nContext();
return (
<select value={locale} onChange={(e) => changeLocale(e.target.value)}>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
</select>
);
}
API Reference
makeI18n(domain, app)
Create isolated i18n instance for a domain/app.
Parameters:
domain(string): Domain name (e.g., 'landing', 'portal', 'admin')app(string): App name (e.g., 'home', 'dashboard', 'settings')
Returns:
{
I18nProvider: React.ComponentType<{
children: ReactNode;
apiUrl: string;
config?: Partial<MakeI18nConfig>;
queryClient?: QueryClient;
}>;
useI18n: () => TranslationProxy;
useI18nContext: () => I18nContextValue;
}
Example:
const { I18nProvider, useI18n, useI18nContext } = makeI18n('portal', 'settings');
I18nProvider
React context provider for domain/app translations. Supports two modes:
- Bundled Mode: Pass
resourcesfor instant loading (recommended) - API Mode: Pass
apiUrlfor dynamic/ML translations
Props:
interface I18nProviderProps {
children: ReactNode;
// Mode 1: Bundled Resources (recommended)
resources?: BundledResources; // Bundled translations by locale/namespace
// Mode 2: API (for dynamic/ML translations)
apiUrl?: string; // Base API URL (e.g., '/api/i18n')
config?: {
defaultLocale?: string; // Default: 'en'
supportedLocales?: string[]; // Default: auto-detect from resources
defaultNamespace?: string; // Default: 'common'
cacheTime?: number; // Default: 24 hours (API mode only)
staleTime?: number; // Default: Infinity (API mode only)
debug?: boolean; // Default: false
customFetch?: typeof fetch; // Custom fetch (API mode only)
};
queryClient?: QueryClient; // External QueryClient (API mode only)
}
Bundled Mode Example (Recommended):
import { resources } from './locales';
<I18nProvider
resources={resources}
config={{
defaultLocale: 'en',
defaultNamespace: 'common',
debug: process.env.NODE_ENV === 'development',
}}
>
<App />
</I18nProvider>
API Mode Example:
<I18nProvider
apiUrl="/api/i18n"
config={{
defaultLocale: 'en',
supportedLocales: ['en', 'es', 'fr', 'de', 'ja'],
debug: process.env.NODE_ENV === 'development',
}}
>
<App />
</I18nProvider>
useI18n(namespace?)
Hook that returns a proxy object for dot notation access.
Parameters:
namespace(optional): String or array of namespace(s) to load. Defaults todefaultNamespacefrom config.
Returns: TranslationProxy
Features:
- Dot notation:
i18n.hero.title - Nested access:
i18n.features.list.items[0].title - Fallback to key if not loaded:
i18n.missing.key→"missing.key" - TypeScript support: Cast to interface for autocomplete
- Namespace selection:
useI18n('landing')oruseI18n(['common', 'landing'])
Examples:
// Using default namespace ('common')
function Component() {
const i18n = useI18n();
return <h1>{i18n.hero.title}</h1>;
}
// Using specific namespace
function LandingPage() {
const i18n = useI18n('landing');
return <h1>{i18n.hero.title}</h1>;
}
// Merging multiple namespaces
function Dashboard() {
const i18n = useI18n(['common', 'dashboard']);
// Access keys from both namespaces
return (
<>
<h1>{i18n.dashboard.title}</h1>
<button>{i18n.cta.primary}</button>
</>
);
}
useI18nContext()
Access i18n context directly (locale, changeLocale, route, etc.).
Returns:
interface I18nContextValue {
locale: string; // Current locale (e.g., 'en')
changeLocale: (locale: string) => void;
domain: string; // Domain (e.g., 'landing')
app: string; // App (e.g., 'home')
route: string; // Current route (from React Router)
isLoading: boolean; // Loading state
apiUrl: string; // API URL
}
Example:
function LanguageInfo() {
const { locale, domain, app, route } = useI18nContext();
return (
<div>
<p>Locale: {locale}</p>
<p>Domain: {domain}</p>
<p>App: {app}</p>
<p>Route: {route}</p>
</div>
);
}
prefetchRoute(queryClient, domain, app, route, locale, apiUrl)
Preload translations for a route (useful for React Router loaders).
Parameters:
queryClient(QueryClient): React Query clientdomain(string): Domain nameapp(string): App nameroute(string): Route pathlocale(string): Locale codeapiUrl(string): API base URL
Example:
// React Router loader
export async function loader() {
const queryClient = new QueryClient();
await prefetchRoute(
queryClient,
'landing',
'home',
'/about',
'en',
'/api/i18n'
);
return { queryClient };
}
Advanced Usage
Type Safety with TypeScript
Define translation structure for autocomplete:
// src/apps/landing/types.ts
interface LandingTranslations {
hero: {
title: string;
subtitle: string;
};
cta: {
primary: string;
secondary: string;
};
features: {
title: string;
list: Array<{
title: string;
description: string;
}>;
};
}
// src/apps/landing/HomePage.tsx
function HomePage() {
const i18n = useI18n() as LandingTranslations;
// TypeScript autocompletes these keys:
return (
<>
<h1>{i18n.hero.title}</h1>
<p>{i18n.hero.subtitle}</p>
<button>{i18n.cta.primary}</button>
</>
);
}
Custom Fetch with Authentication
Add auth headers to translation requests:
async function authenticatedFetch(input: RequestInfo, init?: RequestInit) {
const token = localStorage.getItem('auth_token');
return fetch(input, {
...init,
headers: {
...init?.headers,
'Authorization': `Bearer ${token}`,
},
});
}
<I18nProvider
apiUrl="/api/i18n"
config={{ customFetch: authenticatedFetch }}
>
<App />
</I18nProvider>
Shared QueryClient Across Apps
Share cache between multiple apps:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const landingI18n = makeI18n('landing', 'home');
const portalI18n = makeI18n('portal', 'dashboard');
function MultiAppRoot() {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<landingI18n.I18nProvider apiUrl="/api/i18n" queryClient={queryClient}>
<LandingApp />
</landingI18n.I18nProvider>
<portalI18n.I18nProvider apiUrl="/api/i18n" queryClient={queryClient}>
<PortalApp />
</portalI18n.I18nProvider>
</QueryClientProvider>
);
}
Debug Mode
Enable debug logging for development:
<I18nProvider
apiUrl="/api/i18n"
config={{
debug: process.env.NODE_ENV === 'development',
}}
>
<App />
</I18nProvider>
Console output:
[i18n] Translation not loaded yet, returning key: hero.title
[i18n] Translation missing for key: missing.key, returning key
SSR Support
Server-Side Rendering Flow
┌──────────────────────────────────────────────────────────┐
│ 1. Server: Load translations │
│ const ssrData = await loadSSRTranslations(...) │
└────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 2. Server: Inject hydration script into HTML │
│ <script>{generateHydrationScript(ssrData)}</script> │
└────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 3. Client: Hydrate with pre-loaded data │
│ <SSRReadyI18nProvider ssrData={ssrData}> │
│ <App /> │
│ </SSRReadyI18nProvider> │
└──────────────────────────────────────────────────────────┘
Remix Example
Loader (Server):
// app/routes/about.tsx
import { json } from '@remix-run/node';
import { loadSSRTranslations } from '@lilith/i18n';
export async function loader({ request }: LoaderArgs) {
const url = new URL(request.url);
const locale = url.searchParams.get('lang') || 'en';
const translations = await loadSSRTranslations({
domain: 'landing',
app: 'home',
route: url.pathname,
locale,
apiUrl: process.env.I18N_API_URL,
});
return json({ translations });
}
Component (Client):
import { useLoaderData } from '@remix-run/react';
import { SSRReadyI18nProvider } from '@lilith/i18n';
export default function AboutPage() {
const { translations } = useLoaderData<typeof loader>();
return (
<SSRReadyI18nProvider
domain="landing"
app="home"
apiUrl="/api/i18n"
ssrData={translations}
>
<AboutContent />
</SSRReadyI18nProvider>
);
}
Next.js Example
getServerSideProps:
import { loadSSRTranslations } from '@lilith/i18n';
export async function getServerSideProps(context) {
const locale = context.query.lang || 'en';
const translations = await loadSSRTranslations({
domain: 'landing',
app: 'home',
route: context.resolvedUrl,
locale,
apiUrl: process.env.I18N_API_URL,
});
return {
props: { translations },
};
}
Page Component:
import { SSRReadyI18nProvider } from '@lilith/i18n';
export default function Page({ translations }) {
return (
<SSRReadyI18nProvider
domain="landing"
app="home"
apiUrl="/api/i18n"
ssrData={translations}
>
<Content />
</SSRReadyI18nProvider>
);
}
Performance
Caching Strategy
- Stale Time:
Infinity(translations rarely change) - Cache Time:
24 hours(keep in memory for 24h after last use) - Deduplication: React Query prevents duplicate requests
- Prefetching: Preload routes on hover or navigation
Bundle Size
- Core: ~5KB gzipped
- No heavy dependencies (React Query already in your app)
- Tree-shakable exports
Network Optimization
// Prefetch on hover
function NavLink({ to, children }) {
const onMouseEnter = () => {
prefetchRoute(queryClient, 'landing', 'home', to, 'en', '/api/i18n');
};
return (
<Link to={to} onMouseEnter={onMouseEnter}>
{children}
</Link>
);
}
Migration Guide
From i18next
Before:
import { useTranslation } from 'react-i18next';
function Component() {
const { t } = useTranslation('landing-home');
return <h1>{t('hero.title')}</h1>;
}
After:
import { useI18n } from './i18n.js'; // makeI18n('landing', 'home')
function Component() {
const i18n = useI18n();
return <h1>{i18n.hero.title}</h1>;
}
From Namespace-based to Factory
Before:
<I18nProvider config={{ namespaces: ['landing-home', 'common'] }}>
<App />
</I18nProvider>
After:
const { I18nProvider } = makeI18n('landing', 'home');
<I18nProvider apiUrl="/api/i18n">
<App />
</I18nProvider>
Troubleshooting
Translations not loading
Check:
- API URL is correct:
<I18nProvider apiUrl="/api/i18n"> - Route exists: Verify
/api/i18n/en/landing/home/returns JSON - React Router is installed:
useLocation()requires react-router-dom - QueryClient is provided: Either internal or external
Debug:
<I18nProvider apiUrl="/api/i18n" config={{ debug: true }}>
<App />
</I18nProvider>
TypeScript errors
Problem: Property 'hero' does not exist on type 'TranslationProxy'
Solution: Cast to your translation interface:
interface MyTranslations {
hero: { title: string };
}
const i18n = useI18n() as MyTranslations;
SSR hydration mismatch
Problem: Content flashes on client-side render
Solution: Ensure ssrData is passed correctly:
<SSRReadyI18nProvider ssrData={translations}>
{/* translations must come from loader */}
</SSRReadyI18nProvider>
License
MIT
Contributing
See main repository for contribution guidelines.