platform-codebase/@packages/@infrastructure/i18n/MAKEI18N_README.md
Quinn Ftw 04b70dfc0d feat(i18n): add bundled resources support to makeI18n factory
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>
2025-12-28 17:41:57 -08:00

18 KiB

makeI18n Factory Architecture

Production-ready i18n system with domain/app isolation, bundled resources, and SSR support.

Table of Contents


Overview

The makeI18n factory creates isolated i18n contexts per domain/app with:

  • Factory Pattern: Each app gets its own I18nProvider and useI18n hook
  • 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
};
// 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 resources for instant loading (recommended)
  • API Mode: Pass apiUrl for 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 to defaultNamespace from 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') or useI18n(['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 client
  • domain (string): Domain name
  • app (string): App name
  • route (string): Route path
  • locale (string): Locale code
  • apiUrl (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:

  1. API URL is correct: <I18nProvider apiUrl="/api/i18n">
  2. Route exists: Verify /api/i18n/en/landing/home/ returns JSON
  3. React Router is installed: useLocation() requires react-router-dom
  4. 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.