Skip to content

Instantly share code, notes, and snippets.

@warborn
Created May 25, 2025 00:37
Show Gist options
  • Save warborn/245a5de41a190cf14e78cae178d4e08a to your computer and use it in GitHub Desktop.
Save warborn/245a5de41a190cf14e78cae178d4e08a to your computer and use it in GitHub Desktop.

Internationalization with LinguiJS - Complete Implementation Guide

This guide provides a step-by-step implementation of internationalization (i18n) using LinguiJS in a Vite + React + TypeScript project. It includes solutions to common pitfalls and ensures a working setup in one shot.

Table of Contents

  1. Prerequisites
  2. Installation
  3. Configuration
  4. Core Setup
  5. Context Provider
  6. Routing Setup
  7. Language Switcher
  8. Component Localization
  9. Translation Management
  10. Critical Fixes
  11. Testing
  12. Common Pitfalls

Prerequisites

  • React 18+ with TypeScript
  • Vite build tool
  • React Router (for route-based locales)
  • Project with "type": "module" in package.json

Installation

Install the required LinguiJS packages:

npm install @lingui/react @lingui/core
npm install -D @lingui/cli @lingui/vite-plugin @lingui/babel-plugin-lingui-macro

Configuration

1. Vite Configuration

Update your vite.config.ts:

import path from "path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { lingui } from "@lingui/vite-plugin";

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: ["@lingui/babel-plugin-lingui-macro"],
      },
    }),
    lingui(),
  ],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

2. LinguiJS Configuration

Create lingui.config.js in your project root:

import { defineConfig } from "@lingui/cli";

export default defineConfig({
  sourceLocale: "en",
  locales: ["en", "es"], // Add your supported locales
  catalogs: [
    {
      path: "<rootDir>/src/locales/{locale}/messages",
      include: ["src"],
    },
  ],
});

3. Package.json Scripts

⚠️ CRITICAL: Add these scripts to your package.json. The compile script includes a fix for ES module compatibility:

{
  "scripts": {
    "extract": "lingui extract",
    "compile": "lingui compile && for file in src/locales/*/messages.js; do sed -i 's/module.exports={messages:\\(.*\\)};$/export const messages = \\1;/' $file; done"
  }
}

Why this is needed: LinguiJS compiles to CommonJS format (module.exports) but projects with "type": "module" require ES modules (export const). The sed command converts the format after compilation.

Core Setup

1. i18n Configuration

Create src/i18n.ts:

import { i18n } from "@lingui/core";

// Dynamic imports to avoid TypeScript declaration issues
async function loadMessages(locale: string) {
  const module = await import(`./locales/${locale}/messages.js`);
  return module.messages;
}

// Initialize with all locales
let localesLoaded = false;

async function ensureLocalesLoaded() {
  if (localesLoaded) return;

  try {
    const [enMessages, esMessages] = await Promise.all([
      loadMessages("en"),
      loadMessages("es"),
    ]);

    i18n.load({
      en: enMessages,
      es: esMessages,
    });

    localesLoaded = true;
  } catch (error) {
    console.error("Failed to load locale messages:", error);
  }
}

// Get locale from URL path or default to 'en'
export function getLocaleFromPath(): string {
  const path = window.location.pathname;
  if (path.startsWith("/es")) {
    return "es";
  }
  return "en";
}

// Setup i18n with locale
export async function setupI18n(locale: string = getLocaleFromPath()) {
  await ensureLocalesLoaded();
  i18n.activate(locale);
  return i18n;
}

export { i18n };

Context Provider

Create src/contexts/LocaleContext.tsx:

import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  ReactNode,
} from "react";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { getLocaleFromPath, setupI18n } from "../i18n";

interface LocaleContextType {
  locale: string;
  setLocale: (locale: string) => Promise<void>;
}

const LocaleContext = createContext<LocaleContextType | undefined>(undefined);

export const useLocale = () => {
  const context = useContext(LocaleContext);
  if (!context) {
    throw new Error("useLocale must be used within a LocaleProvider");
  }
  return context;
};

interface LocaleProviderProps {
  children: ReactNode;
}

export const LocaleProvider: React.FC<LocaleProviderProps> = ({ children }) => {
  const [locale, setLocaleState] = useState(getLocaleFromPath());
  const [isLoading, setIsLoading] = useState(true);

  const setLocale = async (newLocale: string) => {
    setIsLoading(true);
    try {
      await setupI18n(newLocale);
      setLocaleState(newLocale);
      
      // Update URL without causing a page reload
      const currentPath = window.location.pathname;
      let newPath: string;

      if (newLocale === "en") {
        // Remove /es prefix if switching to English
        newPath = currentPath.replace(/^\/es/, "") || "/";
      } else if (newLocale === "es") {
        // Add /es prefix if switching to Spanish
        newPath = currentPath.startsWith("/es")
          ? currentPath
          : `/es${currentPath}`;
      } else {
        newPath = currentPath;
      }

      window.history.pushState({}, "", newPath);
    } catch (error) {
      console.error("Failed to switch locale:", error);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    const initializeLocale = async () => {
      setIsLoading(true);
      try {
        await setupI18n(locale);
      } catch (error) {
        console.error("Failed to initialize locale:", error);
      } finally {
        setIsLoading(false);
      }
    };
    
    initializeLocale();
  }, [locale]);

  // Show loading indicator while locale is being loaded
  if (isLoading) {
    return (
      <div className="min-h-screen bg-[#0B0E0F] flex items-center justify-center">
        <div className="text-white">Loading...</div>
      </div>
    );
  }

  return (
    <LocaleContext.Provider value={{ locale, setLocale }}>
      <I18nProvider i18n={i18n} key={locale}>
        {children}
      </I18nProvider>
    </LocaleContext.Provider>
  );
};

Main App Setup

Update your src/main.tsx:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { LocaleProvider } from "./contexts/LocaleContext";
import App from "./App.tsx";
import "./index.css";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <BrowserRouter>
      <LocaleProvider>
        <App />
      </LocaleProvider>
    </BrowserRouter>
  </StrictMode>
);

Routing Setup

Update your src/App.tsx to support locale-based routing:

import { Routes, Route } from "react-router-dom";
import LocalizedHome from "./pages/LocalizedHome";
import Privacy from "./pages/Privacy";

function App() {
  return (
    <Routes>
      {/* English routes */}
      <Route path="/" element={<LocalizedHome />} />
      <Route path="/privacy" element={<Privacy />} />
      
      {/* Spanish routes */}
      <Route path="/es" element={<LocalizedHome />} />
      <Route path="/es/privacy" element={<Privacy />} />
    </Routes>
  );
}

export default App;

Language Switcher

Create src/components/LanguageSwitcher.tsx:

import React from "react";
import { useLocale } from "../contexts/LocaleContext";
import { Globe } from "lucide-react";

export const LanguageSwitcher: React.FC = () => {
  const { locale, setLocale } = useLocale();

  const toggleLanguage = async () => {
    const newLocale = locale === "en" ? "es" : "en";
    await setLocale(newLocale);
  };

  return (
    <button
      onClick={toggleLanguage}
      className="flex items-center gap-2 px-3 py-2 text-sm text-custom-text-secondary hover:text-custom-text-primary transition-colors rounded-lg hover:bg-white/5"
      title={locale === "en" ? "Switch to Spanish" : "Cambiar a inglés"}
    >
      <Globe className="w-4 h-4" />
      <span className="font-medium">{locale === "en" ? "ES" : "EN"}</span>
    </button>
  );
};

Component Localization

Basic Usage

import { Trans, t } from "@lingui/macro";
import { useLingui } from "@lingui/react";

function MyComponent() {
  const { _ } = useLingui();

  return (
    <div>
      {/* For JSX elements */}
      <h1><Trans>Welcome to our app</Trans></h1>
      
      {/* For strings and variables */}
      <p>{_(t`Hello, user!`)}</p>
      
      {/* For dynamic content */}
      <input placeholder={_(t`Enter your email`)} />
      
      {/* For conditional/dynamic text */}
      <span>{isSubmitting ? _(t`Submitting...`) : _(t`Submit`)}</span>
    </div>
  );
}

Locale-aware Navigation

import { useLocale } from "../contexts/LocaleContext";

function Navigation() {
  const { locale } = useLocale();
  
  // Generate locale-aware links
  const privacyLink = locale === "es" ? "/es/privacy" : "/privacy";
  
  return (
    <nav>
      <Link to={privacyLink}>
        <Trans>Privacy Policy</Trans>
      </Link>
    </nav>
  );
}

Translation Management

1. Extract Messages

Run this command to extract all translatable strings from your code:

npm run extract

This creates .po files in src/locales/{locale}/messages.po.

2. Add Translations

Edit the generated .po files to add translations. For example, in src/locales/es/messages.po:

#: src/pages/Home.tsx:25
msgid "Welcome to our app"
msgstr "Bienvenido a nuestra aplicación"

#: src/pages/Home.tsx:30
msgid "Hello, user!"
msgstr "¡Hola, usuario!"

3. Compile Messages

⚠️ CRITICAL: Always use the modified compile script:

npm run compile

This compiles .po files to .js files AND converts them to ES module format.

Critical Fixes

ES Module Compatibility

Problem: LinguiJS compiles to CommonJS but modern projects use ES modules.

Solution: The post-compile script in package.json automatically converts:

  • From: module.exports={messages:{...}};
  • To: export const messages = {...};

Async Loading Issues

Problem: React renders before translations are loaded.

Solution:

  • Use loading states in LocaleProvider
  • Properly await setupI18n calls
  • Add error boundaries

TypeScript Issues

If you encounter TypeScript errors with @lingui/macro:

  1. Ensure you have the babel plugin configured in vite.config.ts
  2. Add to your tsconfig.json:
{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true
  }
}

Testing

Test Language Switching

  1. Visit http://localhost:5173 (English)
  2. Visit http://localhost:5173/es (Spanish)
  3. Use the language switcher to toggle between languages
  4. Verify URL updates correctly without page reload
  5. Verify all text changes to the selected language

Debug Common Issues

// Check if messages are loaded
console.log("Current locale:", i18n.locale);
console.log("Available messages:", Object.keys(i18n.messages[i18n.locale]));

// Check if a specific message exists
const messageId = "some-message-id";
console.log("Message exists:", messageId in i18n.messages[i18n.locale]);

Common Pitfalls

1. ❌ Using the default compile command

# DON'T DO THIS - will not work with ES modules
lingui compile

2. ✅ Always use the modified compile script

# DO THIS - includes ES module conversion
npm run compile

3. ❌ Not handling async loading properly

// DON'T DO THIS - setupI18n is async
useEffect(() => {
  setupI18n(locale); // Missing await
}, [locale]);

4. ✅ Properly await async operations

// DO THIS - properly handle async
useEffect(() => {
  const init = async () => {
    await setupI18n(locale);
  };
  init();
}, [locale]);

5. ❌ Not forcing re-renders after locale changes

// This might not re-render components
<I18nProvider i18n={i18n}>{children}</I18nProvider>

6. ✅ Force re-renders with key prop

// This ensures re-renders when locale changes
<I18nProvider i18n={i18n} key={locale}>{children}</I18nProvider>

7. ❌ Forgetting to extract and compile

Always run both commands when adding new translations:

npm run extract  # Extract new messages
npm run compile  # Compile to JS with ES module fix

File Structure

After implementation, your project should have this structure:

src/
├── contexts/
│   └── LocaleContext.tsx
├── components/
│   └── LanguageSwitcher.tsx
├── locales/
│   ├── en/
│   │   ├── messages.po
│   │   └── messages.js
│   └── es/
│       ├── messages.po
│       └── messages.js
├── pages/
│   └── LocalizedHome.tsx
├── i18n.ts
├── main.tsx
└── App.tsx

Notes for Future Implementation

  1. Always use the modified compile script - this is the most critical part
  2. Handle async loading properly - use loading states and proper error handling
  3. Test both routes - / and /es should work independently
  4. Verify ES module format - compiled messages should use export const messages
  5. Use key prop on I18nProvider - ensures proper re-renders
  6. Extract before compile - always run extract first, then compile
  7. Check browser console - look for import/export errors during development

This guide should enable you to implement LinguiJS internationalization successfully in one shot without the trial-and-error process we experienced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment