Next.js i18n Routing Testing: Locale Detection and URL Strategies
Next.js has built-in i18n routing, but testing it is non-trivial. The routing behavior depends on request headers, cookies, the next.config.js configuration, and your chosen URL strategy. This guide covers unit testing locale-aware pages, integration testing routing logic, and end-to-end Playwright tests that exercise locale switching in a real browser.
i18n Config Under Test
A typical next.config.js i18n setup:
// next.config.js
module.exports = {
i18n: {
locales: ['en', 'fr', 'de'],
defaultLocale: 'en',
localeDetection: true,
},
};The two URL strategies behave differently in tests:
- Subpath (
/fr/about) — testable with path manipulation - Domain (
fr.example.com/about) — requires hostname mocking
Unit Testing Pages with getStaticProps and Locale
getStaticProps receives a locale parameter. Test this directly without spinning up a server:
import { getStaticProps } from '../pages/about';
test('returns English content for en locale', async () => {
const result = await getStaticProps({ locale: 'en', params: {} });
expect(result.props.title).toBe('About Us');
});
test('returns French content for fr locale', async () => {
const result = await getStaticProps({ locale: 'fr', params: {} });
expect(result.props.title).toBe('À propos');
});
test('falls back to English for unsupported locale', async () => {
const result = await getStaticProps({ locale: 'ja', params: {} });
expect(result.props.locale).toBe('en');
expect(result.props.title).toBe('About Us');
});Similarly for getServerSideProps:
import { getServerSideProps } from '../pages/dashboard';
test('loads dashboard data for de locale', async () => {
const context = {
locale: 'de',
req: { headers: { 'accept-language': 'de-DE,de;q=0.9' } },
res: { setHeader: jest.fn() },
};
const result = await getServerSideProps(context);
expect(result.props.currency).toBe('EUR');
expect(result.props.dateFormat).toBe('DD.MM.YYYY');
});Testing the useRouter Locale
Components that read locale from useRouter need the router mock:
import { render, screen } from '@testing-library/react';
import { useRouter } from 'next/router';
import LocaleDisplay from '../components/LocaleDisplay';
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
test('displays current locale from router', () => {
useRouter.mockReturnValue({
locale: 'fr',
locales: ['en', 'fr', 'de'],
defaultLocale: 'en',
pathname: '/about',
asPath: '/fr/about',
push: jest.fn(),
});
render(<LocaleDisplay />);
expect(screen.getByText('FR')).toBeInTheDocument();
});Testing the Locale Switcher Component
A locale switcher typically calls router.push with a new locale. Test the push call:
import { fireEvent, render, screen } from '@testing-library/react';
import { useRouter } from 'next/router';
import LocaleSwitcher from '../components/LocaleSwitcher';
jest.mock('next/router', () => ({ useRouter: jest.fn() }));
test('pushes new locale on selection', () => {
const push = jest.fn();
useRouter.mockReturnValue({
locale: 'en',
locales: ['en', 'fr', 'de'],
pathname: '/about',
query: {},
asPath: '/about',
push,
});
render(<LocaleSwitcher />);
fireEvent.click(screen.getByRole('option', { name: 'Français' }));
expect(push).toHaveBeenCalledWith(
{ pathname: '/about', query: {} },
'/about',
{ locale: 'fr' }
);
});Testing Locale Detection Middleware
If you use Next.js middleware for locale detection, test the detection logic separately from Next.js:
// middleware/detectLocale.js
export function detectLocale(acceptLanguage, supportedLocales, defaultLocale) {
const preferred = acceptLanguage
.split(',')
.map(l => l.split(';')[0].trim().substring(0, 2));
return preferred.find(l => supportedLocales.includes(l)) || defaultLocale;
}import { detectLocale } from '../middleware/detectLocale';
const locales = ['en', 'fr', 'de'];
test('detects French from accept-language header', () => {
expect(detectLocale('fr-FR,fr;q=0.9,en;q=0.8', locales, 'en')).toBe('fr');
});
test('falls back to default when no match', () => {
expect(detectLocale('ja-JP,ja;q=0.9', locales, 'en')).toBe('en');
});
test('handles wildcard accept-language', () => {
expect(detectLocale('*', locales, 'en')).toBe('en');
});
test('picks first supported from priority list', () => {
expect(detectLocale('de;q=0.9,fr;q=0.8', locales, 'en')).toBe('de');
});Playwright End-to-End Tests
Playwright tests are the most reliable way to verify actual locale routing behavior. Run these against a real Next.js server:
// tests/i18n-routing.spec.ts
import { test, expect } from '@playwright/test';
test.describe('subpath locale routing', () => {
test('default locale serves English at root', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL('/');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
test('fr locale serves French content at /fr/', async ({ page }) => {
await page.goto('/fr/');
await expect(page).toHaveURL('/fr/');
await expect(page.getByRole('heading', { name: 'Bienvenue' })).toBeVisible();
});
test('locale switcher updates URL and content', async ({ page }) => {
await page.goto('/');
await page.selectOption('[data-testid="locale-switcher"]', 'fr');
await expect(page).toHaveURL('/fr/');
await expect(page.getByRole('heading', { name: 'Bienvenue' })).toBeVisible();
});
test('direct navigation to /de/ serves German', async ({ page }) => {
await page.goto('/de/about');
await expect(page.getByText('Über uns')).toBeVisible();
});
});For testing locale detection via Accept-Language:
test('redirects to fr based on accept-language header', async ({ browser }) => {
const context = await browser.newContext({
extraHTTPHeaders: { 'Accept-Language': 'fr-FR,fr;q=0.9' },
});
const page = await context.newPage();
await page.goto('/');
await expect(page).toHaveURL('/fr/');
await context.close();
});CI Matrix for Multi-Locale Testing
Run Playwright tests against each locale in CI to catch regressions:
# .github/workflows/i18n.yml
jobs:
test-locales:
strategy:
matrix:
locale: [en, fr, de]
steps:
- name: Run i18n tests
run: npx playwright test --grep "locale routing"
env:
TEST_LOCALE: ${{ matrix.locale }}
BASE_URL: http://localhost:3000Reference process.env.TEST_LOCALE in tests to parameterize locale-specific assertions.
Key Things to Test
getStaticProps/getServerSidePropsreturn locale-correct data for each locale- Missing locale keys fall back gracefully (no raw key strings in the UI)
- Locale switcher updates both the URL and the page content
Accept-Languagedetection redirects to the right subpath- Links within locale-prefixed pages preserve the locale prefix
- The default locale does not add a subpath prefix (unless configured otherwise)
Next.js i18n routing bugs usually surface at the boundary between routing and data fetching. Unit test the data layer, integration test the router mock, and use Playwright to verify the full round-trip.