Implementing Localization in a Next.js App

Anthony Coffey
Category: Web Development

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! πŸš€