The multilingual challenge in a headless architecture
In a traditional WordPress architecture, translation plugins (WPML, Polylang) handle the entire multilingual process: storing translations, selecting the language, rendering pages in the right language and generating localized URLs. In headless mode, this process is split between two layers: WordPress stores and organizes translations, while Next.js handles language routing, display and interface translation strings.
WordPress translation plugins and their API compatibility
WPML (WordPress Multilingual Plugin)
WPML is the most widely used multilingual plugin on WordPress. It stores each translation as a separate post in the database, linked together by a translation identifier.
API compatibility:
- REST API: WPML adds the
?lang=parameter to REST endpoints. Example:/wp-json/wp/v2/posts?lang=enreturns posts in English. - GraphQL: WPML provides an extension for WPGraphQL that exposes translation fields in GraphQL queries.
Polylang
Polylang is a free alternative (base version) to WPML. It works similarly: each translation is a separate post, associated with a language taxonomy.
API compatibility:
- REST API: the companion Polylang REST API plugin (or Polylang Pro) adds the
?lang=parameter to endpoints. - GraphQL: the Polylang for WPGraphQL plugin adds translation fields to GraphQL types.
i18n routing in Next.js (App Router)
The Next.js App Router handles multilingual support via dynamic route segments. The folder structure uses a [locale] parameter that determines the language of each page.
File structure
app/
├── [locale]/
│ ├── layout.tsx # Layout with the locale
│ ├── page.tsx # Localized home page
│ ├── articles/
│ │ ├── page.tsx # Article listing
│ │ └── [slug]/
│ │ └── page.tsx # Individual article
│ └── a-propos/
│ └── page.tsx
├── middleware.ts # Language detection and redirection
└── i18n/
├── config.ts # Locales configuration
├── fr.json # French UI translations
└── en.json # English UI translations
Locales configuration
// i18n/config.ts
export const locales = ['fr', 'en'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'fr';
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale);
}
Language detection middleware
The Next.js middleware intercepts each request and redirects the user to the appropriate language version if no locale is specified in the URL.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { locales, defaultLocale } from './i18n/config';
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Check whether a locale is already in the URL
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return NextResponse.next();
// Detect the browser's preferred language
const acceptLanguage = request.headers.get('accept-language') || '';
const preferredLocale = acceptLanguage.includes('fr') ? 'fr' : 'en';
const locale = preferredLocale || defaultLocale;
// Redirect to the URL with the locale
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Language detection: priorities
The recommended priority order for language detection is: 1) locale in the URL (explicit), 2) user preference cookie (if already set), 3) the browser's Accept-Language header, 4) default locale. The middleware should only redirect requests without a locale in the URL.
Translating interface strings (next-intl)
Interface strings (buttons, labels, messages) do not come from WordPress. They are stored in local JSON files and loaded by a translation system on the Next.js side.
// i18n/fr.json
{
"nav": {
"home": "Accueil",
"articles": "Articles",
"about": "À propos",
"contact": "Contact"
},
"articles": {
"readMore": "Lire la suite",
"publishedOn": "Publié le {date}",
"byAuthor": "Par {author}",
"noResults": "Aucun article trouvé."
},
"common": {
"loading": "Chargement...",
"error": "Une erreur est survenue.",
"backToHome": "Retour à l'accueil"
}
}
// lib/get-translations.ts — loading translations
import { Locale } from '@/i18n/config';
const translations: Record<string, any> = {
fr: () => import('@/i18n/fr.json').then((m) => m.default),
en: () => import('@/i18n/en.json').then((m) => m.default),
};
export async function getTranslations(locale: Locale) {
return translations[locale]();
}
// Usage in a Server Component
// const t = await getTranslations(locale);
// <h1>{t.nav.home}</h1>
Configure the WordPress translation plugin
Install WPML or Polylang on WordPress. Configure the available languages and translate the content (posts, pages, categories). Verify that the REST or GraphQL API returns content filtered by language with the ?lang= parameter.
Set up i18n routing in Next.js
Create the app/[locale]/ folder structure and configure the locales in a configuration file. Each page receives the locale as a route parameter.
Implement the detection middleware
Create the middleware.ts file to redirect visitors without a locale in the URL to their preferred language. Store the preference in a cookie for subsequent visits.
Translate interface strings
Create the JSON translation files (fr.json, en.json) for interface elements. Use next-intl or a custom loader to inject translations into components.
Fetch localized content from WordPress
In Server Components, add the ?lang= parameter to WordPress API requests to retrieve content in the language matching the route's locale.
Add hreflang tags and a language switcher
Implement hreflang tags in generateMetadata for SEO and create a language switcher component in the navigation.
Fetching localized content from the WordPress API
// lib/wordpress.ts — localized requests
import { Locale } from '@/i18n/config';
export async function getLocalizedPosts(locale: Locale) {
const response = await fetch(
`${process.env.WORDPRESS_API_URL}/wp/v2/posts?lang=${locale}&per_page=20&_embed`,
{ next: { revalidate: 3600 } }
);
return response.json();
}
export async function getLocalizedPage(slug: string, locale: Locale) {
const response = await fetch(
`${process.env.WORDPRESS_API_URL}/wp/v2/pages?slug=${slug}&lang=${locale}&_embed`
);
const pages = await response.json();
return pages[0] || null;
}
// app/[locale]/articles/page.tsx
import { getLocalizedPosts } from '@/lib/wordpress';
import { getTranslations } from '@/lib/get-translations';
import { Locale } from '@/i18n/config';
type Props = { params: { locale: Locale } };
export default async function ArticlesPage({ params }: Props) {
const { locale } = params;
const [posts, t] = await Promise.all([
getLocalizedPosts(locale),
getTranslations(locale),
]);
return (
<main>
<h1>{t.nav.articles}</h1>
{posts.map((post: any) => (
<article key={post.id}>
<h2>{post.title.rendered}</h2>
<a href={`/${locale}/articles/${post.slug}`}>{t.articles.readMore}</a>
</article>
))}
</main>
);
}
hreflang tags for multilingual SEO
hreflang tags tell search engines about the relationships between language versions of a page. They are configured in generateMetadata.
// app/[locale]/articles/[slug]/page.tsx
import { Metadata } from 'next';
import { locales } from '@/i18n/config';
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = params;
const article = await getLocalizedArticle(slug, locale);
const languages: Record<string, string> = {};
for (const loc of locales) {
languages[loc] = `https://www.your-site.com/${loc}/articles/${slug}`;
}
return {
title: article.title.rendered,
description: article.excerpt.rendered.replace(/<[^>]*>/g, ''),
alternates: {
canonical: `https://www.your-site.com/${locale}/articles/${slug}`,
languages,
},
};
}
Language switcher component
// components/LanguageSwitcher.tsx
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { locales, Locale } from '@/i18n/config';
const localeNames: Record<Locale, string> = {
fr: 'Français',
en: 'English',
};
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname();
return (
<nav aria-label="Language selection">
{locales.map((locale) => {
// Replace the locale in the current path
const newPath = pathname.replace(`/${currentLocale}`, `/${locale}`);
return (
<Link
key={locale}
href={newPath}
lang={locale}
hrefLang={locale}
className={locale === currentLocale ? 'font-bold' : ''}
>
{localeNames[locale]}
</Link>
);
})}
</nav>
);
}
URL structure strategies
Recommended structure for headless Next.js
The subdirectory structure (/fr/, /en/) is the recommended approach for a headless Next.js site. It is natively supported by App Router routing, preserves SEO authority on a single domain and simplifies DNS configuration and deployment. It is also the structure Google recommends for most multilingual sites.
Synchronizing content across languages
Synchronization between language versions is managed on the WordPress side:
- WPML: the built-in "Translation Editor" lets you translate each field side by side and ensures the link between versions
- Polylang: the translation interface associates each post with its translations through a dedicated column in the post list
On the frontend side, synchronization is verified by ensuring that each localized page has an hreflang link pointing to all of its translations. If a translation does not exist, the page must not include an hreflang for that missing language.