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.
- Prerequisites
- Installation
- Configuration
- Core Setup
- Context Provider
- Routing Setup
- Language Switcher
- Component Localization
- Translation Management
- Critical Fixes
- Testing
- Common Pitfalls
- React 18+ with TypeScript
- Vite build tool
- React Router (for route-based locales)
- Project with
"type": "module"
in package.json
Install the required LinguiJS packages:
npm install @lingui/react @lingui/core
npm install -D @lingui/cli @lingui/vite-plugin @lingui/babel-plugin-lingui-macro
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"),
},
},
});
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"],
},
],
});
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.
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 };
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>
);
};
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>
);
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;
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>
);
};
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>
);
}
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>
);
}
Run this command to extract all translatable strings from your code:
npm run extract
This creates .po
files in src/locales/{locale}/messages.po
.
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!"
npm run compile
This compiles .po
files to .js
files AND converts them to ES module format.
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 = {...};
Problem: React renders before translations are loaded.
Solution:
- Use loading states in LocaleProvider
- Properly await setupI18n calls
- Add error boundaries
If you encounter TypeScript errors with @lingui/macro
:
- Ensure you have the babel plugin configured in vite.config.ts
- Add to your
tsconfig.json
:
{
"compilerOptions": {
"moduleResolution": "bundler",
"allowImportingTsExtensions": true
}
}
- Visit
http://localhost:5173
(English) - Visit
http://localhost:5173/es
(Spanish) - Use the language switcher to toggle between languages
- Verify URL updates correctly without page reload
- Verify all text changes to the selected language
// 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]);
# DON'T DO THIS - will not work with ES modules
lingui compile
# DO THIS - includes ES module conversion
npm run compile
// DON'T DO THIS - setupI18n is async
useEffect(() => {
setupI18n(locale); // Missing await
}, [locale]);
// DO THIS - properly handle async
useEffect(() => {
const init = async () => {
await setupI18n(locale);
};
init();
}, [locale]);
// This might not re-render components
<I18nProvider i18n={i18n}>{children}</I18nProvider>
// This ensures re-renders when locale changes
<I18nProvider i18n={i18n} key={locale}>{children}</I18nProvider>
Always run both commands when adding new translations:
npm run extract # Extract new messages
npm run compile # Compile to JS with ES module fix
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
- Always use the modified compile script - this is the most critical part
- Handle async loading properly - use loading states and proper error handling
- Test both routes -
/
and/es
should work independently - Verify ES module format - compiled messages should use
export const messages
- Use key prop on I18nProvider - ensures proper re-renders
- Extract before compile - always run extract first, then compile
- 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.