Back to All Articles
reacttypescripti18narchitecture

Building an i18n Localization Engine in React with Type-Safe Dictionaries

Learn how to build a lightweight, type-safe internationalization engine in React with zero external dependencies and fallback handling.

2026-10-12
Building an i18n Localization Engine in React with Type-Safe Dictionaries

Let's build a clean, type-safe localization engine for a React application without dragging in heavy third-party libraries.

Managing copy across multiple languages often turns into a messy JSON-fetching exercise. By leveraging TypeScript mapped types, we can catch missing translation keys at compile time rather than crashing the production UI for international users.

What you'll learn

  • How to structure translation dictionaries using TypeScript const assertions.
  • Building a custom React Context for reactive language switching.
  • Creating type-safe translation hooks with interpolation support.
  • Handling missing keys and fallbacks gracefully in production.

1. Structuring Type-Safe Translation Dictionaries

To make our i18n setup rock-solid, we define our base English dictionary with TypeScript as const assertions. This locks down the exact shape of our copy so other languages must follow the exact same schema.

TSX
// src/i18n/dictionaries/en.ts
export const en = {
  common: {
    welcome: 'Welcome back, {name}',
    save: 'Save changes',
    cancel: 'Cancel',
  },
  dashboard: {
    title: 'Your Overview',
    activeUsers: 'Active Users: {count}',
  },
} as const;

export type Dictionary = typeof en;

Speed Note: Using as const allows TypeScript to infer exact string literal types for every nested key, unlocking powerful autocomplete inside your editor.

2. Implementing the Locale Context and Provider

Next, we need a React Context to manage the active language state and persist user preference in localStorage. This provider exposes a translation function wrapped in a custom hook.

TSX
// src/i18n/I18nContext.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { en, Dictionary } from './dictionaries/en';
import { es } from './dictionaries/es';

type Locale = 'en' | 'es';
const dictionaries: Record<Locale, Dictionary> = { en, es };

interface I18nContextType {
  locale: Locale;
  setLocale: (locale: Locale) => void;
  t: (path: string, params?: Record<string, string | number>) => string;
}

const I18nContext = createContext<I18nContextType | null>(null);

export function I18nProvider({ children }: { children: ReactNode }) {
  const [locale, setLocale] = useState<Locale>('en');

  const t = (path: string, params?: Record<string, string | number>) => {
    const keys = path.split('.');
    let value: any = dictionaries[locale];

    for (const key of keys) {
      value = value?.[key];
    }

    // Fallback to English if translation is missing
    if (!value) {
      let fallback: any = dictionaries['en'];
      for (const key of keys) {
        fallback = fallback?.[key];
      }
      value = fallback || path;
    }

    // Interpolate parameters like {name} or {count}
    if (params) {
      return Object.entries(params).reduce(
        (acc, [key, val]) => acc.replace(new RegExp(`\\{${key}\\}`, 'g'), String(val)),
        value
      );
    }

    return value;
  };

  return (
    <I18nContext.Provider value={{ locale, setLocale, t }}>
      {children}
    </I18nContext.Provider>
  );
}

export function useI18n() {
  const context = useContext(I18nContext);
  if (!context) throw new Error('useI18n must be used within an I18nProvider');
  return context;
}

3. Consuming Translations in Components

With our hook ready, rendering localized copy becomes straightforward. We pass dot-notation paths to the t function and supply an optional parameters object for dynamic variables.

TSX
// src/components/DashboardHeader.tsx
import React from 'react';
import { useI18n } from '../i18n/I18nContext';

export function DashboardHeader() {
  const { t, locale, setLocale } = useI18n();

  return (
    <header className="flex justify-between items-center p-4 border-b">
      <div>
        <h1 className="text-xl font-bold">{t('dashboard.title')}</h1>
        <p className="text-gray-500">{t('common.welcome', { name: 'Alex' })}</p>
      </div>

      <div className="flex gap-2">
        <button 
          onClick={() => setLocale(locale === 'en' ? 'es' : 'en')}
          className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200"
        >
          Switch to {locale === 'en' ? 'Español' : 'English'}
        </button>
      </div>
    </header>
  );
}

Gotcha: Always provide a fallback key or full English string lookup when a translation is missing. Crashing a render tree over a missing string in production destroys user trust.

4. Enforcing Strict Key Paths with TypeScript

To prevent developers from passing random strings into t(), we can map out recursive key paths using conditional TypeScript template literal types.

TSX
// src/i18n/types.ts
import { Dictionary } from './dictionaries/en';

type Prev = [never, 0, 1, 2, 3, 4, 5];

type Leaves<T, D extends number = 3> = [D] extends [never]
  ? never
  : T extends object
  ? {
      [K in keyof T]-?: K extends string
        ? T[K] extends object
          ? `${K}.${Leaves<T[K], Prev[D]>}`
          : K
        : never;
    }[keyof T]
  : '';

export type TranslationKey = Leaves<Dictionary>;

Updating your t function parameter type to TranslationKey gives you robust editor autocomplete and catches typos at build time.

Summary

Building a custom i18n layer keeps your bundle size minimal and gives you total control over fallback behaviors, pluralization rules, and interpolation formats.

Next Steps

  • Implement pluralization handlers based on ICU message syntax or simple count checks.
  • Add dynamic chunk loading for larger dictionaries to avoid loading all languages upfront.
  • Set up CI checks to scan pull requests for missing translation keys in secondary dictionaries.