- Next.js Internationalization: Complete Architecture Guide
Next.js Internationalization: Complete Architecture Guide
Three-layer i18n architecture for Next.js App Router: routing, Payload CMS content, and hreflang with next-intl.

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
If you've ever added i18n to a Next.js project and felt like you were fighting three different problems at once, you weren't imagining it. You were.
Internationalization in Next.js isn't a single problem with a single solution. It's three separate architectural layers that need to work in sync — and most tutorials only cover one of them. You add next-intl, get the routing working, then realize your CMS content is still English-only. You fix the CMS, then discover your hreflang tags are missing or wrong. You fix those, then a locale-specific cache revalidation slips through and one language version of your site serves stale content for a week.
I've built multilingual sites on Next.js with Payload CMS, dealt with the Next.js 16 middleware breaking changes that affect next-intl, and debugged enough hreflang issues to know exactly where things fall apart. This guide maps the full architecture — not to walk you through every implementation step, but to show you how the layers connect and where to focus first.
If you've worked with Next.js before the App Router era, you probably remember configuring i18n in next.config.js. A few lines, subpath routing, locale detection — done. That's gone. When Next.js moved to the App Router, it removed the built-in i18n routing configuration entirely.
That's not a bug or an oversight. It's a deliberate architectural shift. The App Router expects you to handle locale routing through Middleware and dynamic route segments. Practically, this means your folder structure changes — pages live under a [lang] directory, and a Middleware file handles locale detection and rewrites before the request ever reaches a page.
The upside is you get full control. The downside is the surface area for mistakes is much larger. This is exactly the gap next-intl fills — it gives you a createMiddleware helper that handles locale negotiation, redirects, and rewrites in a sane way. It also provides <Link> and useRouter wrappers that preserve the current locale automatically, which saves you from manually threading locale props through every navigation component.
One gotcha worth calling out specifically for Next.js 16: the framework introduced stricter asynchronous request handling (Async Local Storage changes) that broke how several i18n libraries accessed the request context in Middleware and Server Components. next-intl released updates to address this, but if you're on an older version of next-intl with Next.js 16, you'll hit "locale not found" or "failed to forward action response" errors. Update next-intl first — it saves you a frustrating debugging session.
For the full setup walkthrough, the next-intl guide covers the implementation from scratch.
Here's the mental model I use for i18n architecture. Think of it as three distinct layers that each solve a different problem.
Layer 1 is routing — the URL structure and how requests get matched to the right locale. This is where next-intl operates. It answers: does /fr/produits correctly serve French content? Does the default locale suppress the /en prefix if you want clean URLs? Does switching locales preserve the current page instead of redirecting to the homepage?
Layer 2 is content — the actual data in each language. This is where your CMS operates. It answers: does your Payload CMS collection know how to serve a French title and an English title for the same document? When the routing layer requests /fr/produits, does the data layer return the French product descriptions?
Layer 3 is SEO signals — hreflang tags and canonical URLs. This is where most developers either skip entirely or get wrong. It answers: does Google understand that site.com/en/about and site.com/fr/a-propos are the same content in different languages? Is the x-default tag set for users whose language doesn't match any locale?
The reason these feel like one problem is that they're all connected. Layer 1 determines your URL structure, which directly shapes what you put in Layer 3. Layer 2 feeds Layer 1 because the content model in your CMS affects which URLs even exist per locale. If you design these layers independently without thinking about their intersections, you'll spend a lot of time patching mismatches.
The first decision is whether locales live in the path (/en/about), on subdomains (en.site.com), or on separate domains (site.fr). For most projects, sub-path routing is the right default — it's simpler to deploy, easier to manage SSL, and works well with Next.js Middleware.
The trap to avoid here is the default locale handling. Say your default locale is English and you want clean URLs — /about instead of /en/about. This is a common and reasonable choice. The mistake is implementing the /en/about → /about rewrite but then not handling the inverse correctly, so the English page ends up without self-referencing hreflang or appears at two URLs simultaneously. next-intl handles this through the localePrefix configuration option, but you have to understand what it's doing and ensure your sitemap and hreflang tags reflect the actual URLs that are being served.
Navigation inside a multilingual App Router project has another subtlety: when a user switches from /fr/produits to /en, you almost always want to land them on /en/products — not the homepage. That requires the router to know the current page's slug in every locale, which is a content modeling decision as much as a routing one. There's a detailed breakdown of this specific problem in the next-intl locale switch guide.
Payload CMS handles multilingual content in a way that fits naturally into this architecture. Instead of creating separate collections per language — a Page_en and a Page_fr — Payload lets you mark individual fields as localized: true. This means a single document stores all language variants of each field together, while shared data like dates, authors, and relationships stay unified.
// File: src/collections/Pages.ts
import { CollectionConfig } from 'payload'
const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{
name: 'title',
type: 'text',
localized: true, // stored per-locale
},
{
name: 'content',
type: 'richText',
localized: true,
},
{
name: 'publishedAt',
type: 'date',
// no localized flag — shared across all locales
},
],
}
When you query the Local API in a Server Component, you pass the locale parameter and get back the right translation. Payload 3.0 is built directly on Next.js, so these queries happen in-process — no network round trip, just a direct database call. The locale=* option lets you fetch all translations at once when you need to build hreflang tags or generate static params for all locales.
One thing to plan for when enabling localization on an existing collection: Payload treats the existing data as the default locale. Other language fields start empty and rely on fallback logic until you populate them. That's fine as long as you configure fallbacks explicitly in your Payload config — otherwise missing translations return null instead of the default language.
The implementation details for the admin side — including how editors switch between locales in the admin UI — are covered in the Payload CMS multilingual guide.
Hreflang is where multilingual SEO lives or dies, and it has more failure modes than most developers expect.
The four rules that cover the majority of mistakes:
Every localized page must include a self-referencing hreflang tag. The English page needs hreflang="en" pointing back to itself. Skipping this causes Google to misread the link graph and potentially deindex variants.
Every set of localized pages needs an x-default tag. This tells Google which page to show when a user's language doesn't match any of your supported locales. Without it, Google picks for you — and it often picks wrong.
Use correct locale codes. en-GB not uk. pt-BR for Brazilian Portuguese, pt-PT for European. Getting these wrong means Google ignores the tags silently.
Hreflang tags must be consistent across all pages in a set. If /en/about lists /fr/a-propos as its French alternate, then /fr/a-propos must list /en/about as its English alternate. One-way declarations are ignored.
In Next.js App Router, the implementation lives in generateMetadata:
// File: src/app/[lang]/[slug]/page.tsx
import { Metadata } from 'next'
export async function generateMetadata({ params }: { params: { lang: string; slug: string } }): Promise<Metadata> {
const { lang, slug } = await params
const allLocales = ['en', 'fr', 'de']
// fetch slug variants per locale from Payload using locale=*
const slugsByLocale = await getSlugVariants(slug, lang)
const languages: Record<string, string> = {}
for (const locale of allLocales) {
const localizedSlug = slugsByLocale[locale] ?? slug
languages[locale] = `https://site.com/${locale}/${localizedSlug}`
}
languages['x-default'] = `https://site.com/en/${slugsByLocale['en'] ?? slug}`
return {
alternates: {
canonical: `https://site.com/${lang}/${slug}`,
languages,
},
}
}
For large sites, generating hreflang through a dynamic sitemap is preferable to embedding it in every page's <head>. Next.js supports this through app/sitemap.ts where you can list all locales and their alternates in one place, reducing per-page complexity.
The full implementation with canonical tags is covered in the hreflang and canonical tags guide.
Two performance issues are worth flagging before you build rather than after.
Static generation with multiple locales multiplies your build time proportionally. If you have 100 pages and 5 languages, you're generating 500 static pages. This becomes a CI/CD problem, not a runtime problem — but it's still worth knowing upfront. Structure your generateStaticParams to only pre-render the locales that matter at build time and let ISR handle the rest if the page count is large.
Client-side translation files are a bundle size trap. Loading all locale JSON files client-side adds significant weight. The App Router solves this naturally through Server Components — translations happen on the server, only the rendered text reaches the client. This is a strong reason to avoid mixing client component translations with next-intl's server-side getTranslations API. Keep translation logic server-side wherever possible.
One ISR-specific gotcha: Next.js handles cache revalidation per path. When you update a French translation in Payload CMS and trigger revalidation, you need to revalidate /fr/slug — not just /slug. If your revalidatePath calls don't include locale variants, your French content will stay stale until the TTL expires. This catches most developers at least once.
Here's the flow in practice for a request to /fr/produits:
The Middleware (via next-intl) intercepts the request, identifies fr as the locale, and rewrites to the [lang] route. The Server Component receives lang: 'fr' and slug: 'produits' as params. It queries Payload's Local API with locale: 'fr' and gets back the French field values. generateMetadata runs and builds the hreflang alternates from the locale=* query result. The page renders with French content, correct canonical URL, and a full set of hreflang tags.
Each layer does its job independently, but they're all reading from the same source of truth — the locale in the URL, which flows from the routing layer through the data layer into the SEO layer.
Where developers typically break this chain: mismatched slugs across locales (routing layer says /produits but the Payload document stores products as the slug for the French version too), or generateMetadata that hardcodes locales instead of reading them from the CMS, so adding a third language requires code changes instead of just adding content.
This guide is intentionally architectural — it maps the landscape rather than walking through every implementation detail. For the actual implementation in each layer:
Build the layers in that order. Get routing working first, then connect the CMS content, then add hreflang once the URL structure is stable. Trying to implement all three simultaneously is where most projects end up in a tangle.
Let me know in the comments if you run into issues at the intersections — the layer handoffs are where things break, and questions there are usually worth a follow-up post.
Thanks, Matija