I just finished implementing localization for a production Next.js app to support English, Spanish, and Portuguese and here's what I learned along the way.
In this article you will learn how I set up a robust internationalization (i18n) system using i18next and react-i18next.
Localization (l10n) is about adapting your product for a specific locale. Internationalization (i18n) is designing your product so it can be adapted. You need both! π
The Tech Stack π οΈ
We went with a battle-tested stack for this:
- i18next: The core framework. It's been around forever and just works.
- react-i18next: The React bindings (because hooks are life).
- i18next-browser-languagedetector: Automatically detects the user's language preference from their browser or
localStorage.
Supported Languages
- English (en) - Default/Fallback
- Spanish (es)
- Portuguese (pt)
Configuration βοΈ
The heart of our i18n setup lives in src/i18n/index.ts. This file initializes i18next, loads our translation resources, and configures detection.
Here's what the configuration looks like:
// src/i18n/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
import es from './locales/es.json';
import pt from './locales/pt.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
es: { translation: es },
pt: { translation: pt },
},
fallbackLng: 'en',
interpolation: {
escapeValue: false, // React already safe from XSS
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
Notice the detection order? We check localStorage first. This means if a
user manually selects a language, we remember it! π§ If not, we fall back to
their browser settings.
Our translation strings are stored in simple JSON files:
src/i18n/locales/
βββ en.json
βββ es.json
βββ pt.json
Usage in Components π§©
Using the translations is super straightforward thanks to the useTranslation hook.
import { useTranslation } from 'react-i18next';
function WelcomeHeader() {
const { t } = useTranslation();
return (
<header className="p-4 bg-primary text-white">
<h1>{t('welcomeMessage')}</h1>
<p>{t('subtitle')}</p>
</header>
);
}
Make sure your keys match exactly what's in your JSON files! Typos will just return the key string itself, which looks kinda unprofessional. π
The Language Switcher π
We built a custom LanguageSwitcher component to let users swap languages easily. It features a dropdown menu, persists the selection, and even has some nice animations! β¨
Here is the full component code:
import { useState, useRef, useEffect, type JSX } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown } from 'lucide-react';
const LANGUAGES = [
{ code: 'en', label: 'EN' },
{ code: 'es', label: 'ES' },
{ code: 'pt', label: 'PT' },
] as const;
export function LanguageSwitcher(): JSX.Element {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
event.target instanceof Node &&
!menuRef.current.contains(event.target)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
const currentLanguage =
LANGUAGES.find((l) => l.code === i18n.language) || LANGUAGES[0];
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground hover:bg-accent focus:outline-none"
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span>{currentLanguage.label}</span>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{isOpen && (
<div className="absolute right-0 z-50 mt-2 w-32 overflow-hidden rounded-md border border-border bg-popover shadow-md animate-in fade-in-0 zoom-in-95">
<div className="p-1">
{LANGUAGES.map(({ code, label }) => {
const isSelected = i18n.language === code;
return (
<button
key={code}
onClick={() => {
i18n.changeLanguage(code);
setIsOpen(false);
}}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm text-left outline-none transition-colors hover:bg-accent hover:text-accent-foreground ${
isSelected ? 'bg-accent text-accent-foreground' : ''
}`}
role="option"
aria-selected={isSelected}
>
<span>{label}</span>
{isSelected && <Check className="h-4 w-4" />}
</button>
);
})}
</div>
</div>
)}
</div>
);
}
API Integration & Headers π
It's not just the UI that needs to be localizedβthe backend needs to know what language the user prefers too!
We handled this with a simple Axios interceptor that attaches the Accept-Language header to every outgoing request.
// In src/services/api.ts
api.interceptors.request.use((config) => {
// Pass the current language to the backend
config.headers['Accept-Language'] = i18n.language;
return config;
});
Now our API responses (like validation errors or dynamic data) can respect the user's choice automatically. π
Validation Messages (Zod) β
Speaking of validation, we're using Zod for our schemas. To localize error messages, we created a utility function syncZodLocale.
It maps the current i18next language to Zod's built-in locales (z.locales.en, z.locales.es, etc.). We trigger this synchronization whenever the app loads or the language changes. No more English messages coming from the backend for Spanish users!
Testing π§ͺ
Testing localized components can be tricky because localStorage persists between tests, which can lead to flaky results.
To solve this, we use a helper function createTestI18n that creates an isolated instance of i18next for each test.
// src/test/i18nTestHelper.ts
export const createTestI18n = (language = 'en') => {
const i18n = i18next.createInstance();
i18n.init({
lng: language,
fallbackLng: 'en',
resources: {
en: { translation: en },
es: { translation: es },
// ...
},
});
return i18n;
};
Usage in Tests:
import { render } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { createTestI18n } from '../../../test/i18nTestHelper';
test('renders in Spanish', () => {
const i18n = createTestI18n('es'); // Start test in Spanish
render(
<I18nextProvider i18n={i18n}>
<LanguageSwitcher />
</I18nextProvider>,
);
// Assert Spanish content...
});
Backend Bonus: FastAPI & Babel π
Wait, there's more! For the backend, we used the fastapi-babel package, and honestly, it's pretty slick.
In the frontend, we're used to using dot-notation keys like t('home.welcomeMessage'). But in the backend, we used natural language keys.
Instead of home.welcomeMessage, the key is the English string:
_("Welcome to the API")
This makes the backend code so much more readable! You don't have to jump to a translation file to know what a message saysβit's right there in the code. π€―
This approach (gettext style) is super popular in Python/Django worlds. It keeps the codebase searchable and clean. The translation files map "Welcome to the API" -> "Bienvenido a la API".
Conclusion
Implementing localization might seem daunting at first, but with the right tools, it becomes a breeze. π¬οΈ The combination of i18next for the frontend and fastapi-babel for the backend gave us a seamless, end-to-end multi-language experience.
Now go out there and make your apps global! π
Happy coding! π
