

Next.js has two official i18n docs pages that describe completely different systems, with no cross-reference between them.
One covers the Pages Router. The other covers the App Router. Neither explains how the two relate, which libraries to use, or how to handle hreflang for multilingual SEO.
We’re going to bridge both routers. We’ll go over routing strategies, library selection, middleware setup, Server Component translations, and multilingual SEO.
Our aim is to create a useful resource for developers who've been asked to add language support to a Next.js app and need a clear picture before committing to an approach. Let’s get started!
Internationalization has three layers:
Next.js only helps with routing.
The Pages Router has had built-in i18n routing since v10.0.0. A three-field config block in next.config.js handles locale detection, URL prefixing, and redirects automatically.
The App Router is different. Its i18n docs page documents a pattern you assemble yourself using middleware, a [locale] dynamic segment, and a library.
For translations and formatting, you're on your own regardless of which router you use. The App Router docs list all compatible libraries, but offer no guidance on which to choose. Neither docs page covers hreflang tags or multilingual sitemaps.
The Pages Router approach is a config change. Add an i18n block to next.config.js with three fields:
// next.config.js
module.exports = {
i18n: {
locales: ['en', 'fr', 'de'],
defaultLocale: 'en',
},
}That's it.
Next.js automatically detects locale from the Accept-Language header, respects a NEXT_LOCALE cookie override, and exposes the active locale via useRouter().locale. You don’t need any middleware or directory restructuring.
The App Router has no equivalent config. You build the routing yourself from three pieces:
@formatjs/intl-localematcher and negotiator to parse Accept-Language headers. The official docs leave the actual detection logic as an ellipsis: function getLocale(request) { ... }. We’ll fill that in in the next section.[locale] dynamic segment wraps your entire app/ directory. Every route becomes locale-aware through the folder structure.generateStaticParams pre-renders each locale variant at build time.Of the three pieces, generateStaticParams is the one that replaces what the Pages Router handled automatically at build time.
export function generateStaticParams() {
return [{ locale: 'en' }, { locale: 'fr' }, { locale: 'de' }]
}🚨 The Pages Router's built-in i18n doesn't work with output: 'export'. If you need a fully static export, you'll need manual folder-based routing instead. That limitation applies to the built-in config only. Both routers support static exports with a manual setup.
Before choosing a library, choose a routing strategy. It affects SEO, DNS setup, and how much middleware you'll write:
next-intl supports it as a first-class option.For public sites where SEO is vital, sub-path routing is the right default. Domain routing is worth the complexity only if strong geo-targeting signals are a specific requirement.
If you'd rather not manage URL structure manually, Weglot's reverse proxy handles subdirectory and subdomain routing automatically, including hreflang injection and translated URLs.
As we stated earlier, the official docs list libraries without recommending any. Here's how the main options compare:
next-intl is the de facto standard for the App Router. RSC support is built in, routing is integrated, and TypeScript autocompletion works without extra configuration.
react-i18next is the better fit if you're on the Pages Router or already using i18next elsewhere in your stack. App Router support works but requires more manual setup.
Lingui suits teams with existing .po file pipelines. Translations are extracted at build time, so unused messages are never shipped.
💡 Paraglide JS doesn’t really rank among the big players, but it’s worth noting as a compiler-based alternative with smaller bundle output. If you’re interested, though, you’ll need to contend with the fact that its ecosystem is younger than the others.
For date and number formatting, next-intl wraps the native Intl APIs via useFormatter. The others delegate to FormatJS or leave it to you to call Intl.DateTimeFormat and Intl.NumberFormat directly.
💡 These libraries handle rendering. Someone still has to produce the translations and keep them in sync as content changes. Weglot operates at a different layer entirely, managing translation content via reverse proxy outside the codebase. Don’t think of it as a library replacement.
Next.js 16 renamed middleware.ts to proxy.ts. This was done because “middleware” was misleading and implied generic in-app logic. In reality, the file runs at the network boundary, intercepting and modifying requests like a proxy before they reach the application.
Here's a complete locale detection and redirect implementation that fills the aforementioned ellipsis in the official docs:
// proxy.ts
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import { NextRequest, NextResponse } from 'next/server'
const locales = ['en', 'fr', 'de']
const defaultLocale = 'en'
function getLocale(request: NextRequest): string {
const cookie = request.cookies.get('NEXT_LOCALE')?.value
if (cookie && locales.includes(cookie)) return cookie
const headers = { 'accept-language': request.headers.get('accept-language') ?? '' }
const languages = new Negotiator({ headers }).languages()
return match(languages, locales, defaultLocale)
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const hasLocale = locales.some(l => pathname.startsWith(`/${l}/`) || pathname === `/${l}`)
if (hasLocale) return
const locale = getLocale(request)
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
Files nest under app/[locale]/, with a dictionaries/ folder alongside your layouts and pages. The root layout receives locale as a param and sets it on the html tag:
export default async function RootLayout({ children, params }) {
const { locale } = await params
return (
<html lang={locale}>
<body>{children}</body>
</html>
)
}
For translations, the official docs show a raw dictionary pattern that requires no library:
const dictionaries = {
en: () => import('./dictionaries/en.json').then(m => m.default),
fr: () => import('./dictionaries/fr.json').then(m => m.default),
}
export const getDictionary = async (locale: string) => dictionaries[locale]()
This works but has no pluralization or interpolation support. next-intl covers both via getTranslations() and useTranslations() in Server Components and Client Components respectively.
Either way, translation files are processed on the server and only the resulting HTML reaches the browser, so message file size has no impact on client bundle size.
For Client Components, pass translated strings as props from a Server Component parent where possible. Use NextIntlClientProvider with a scoped message subset only when a Client Component needs to handle translations directly.
For a language switcher, a Server Component renders the locale labels while a Client Component handles the useRouter() call:
// LocaleSwitcher.tsx (Server Component)
import LocaleSwitcherClient from './LocaleSwitcherClient'
export default function LocaleSwitcher({ locale }) {
return <LocaleSwitcherClient locale={locale} labels={{ en: 'English', fr: 'Français' }} />
}The client half listens for selection changes and pushes the new locale route:
// LocaleSwitcherClient.tsx
'use client'
import { useRouter } from 'next/navigation'
export default function LocaleSwitcherClient({ locale, labels }) {
const router = useRouter()
return (
<select value={locale} onChange={e => router.push(`/${e.target.value}`)}>
{Object.entries(labels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
)
}If you're using Weglot, none of this applies. Weglot includes a built-in language switcher widget and requires no proxy configuration or middleware.
Neither router handles multilingual SEO automatically:
html lang correctly but its docs say hreflang implementation is "up to you."With the App Router, the Metadata API gets you closest to automation. Define locale URLs in generateMetadata via the alternates.languages object and Next.js auto-injects link rel="alternate" hreflang="..." tags:
export async function generateMetadata({ params: { locale } }) {
return {
alternates: {
languages: {
'en': 'https://example.com/en',
'fr': 'https://example.com/fr',
'de': 'https://example.com/de',
},
},
}
}The manual part is gathering the correct URL for each locale variant per page. That logic is yours to write.
Two other things require manual implementation regardless of which router you use. Canonical URLs need to be set per locale to avoid duplicate content signals. Your sitemap.ts needs xhtml:link entries for every locale variant of every page.
On the hreflang value itself, use hreflang="en-US" when targeting a specific region, and hreflang="en" when targeting all English speakers. Mixing these up sends conflicting signals to search engines.
next-intl adds one partial automation: its proxy automatically injects hreflang Link response headers when you're using localized pathname routing. Other routing configurations still require manual implementation.
That's a meaningful amount of ongoing maintenance as your site grows. Luckily, Weglot's reverse proxy handles everything automatically on the server, so translated pages are fully indexable without additional code.
In fact, Weglot's reverse proxy handles everything covered in the previous section automatically: hreflang tags, translated URLs, canonical tags, and sitemap generation. Translated pages are served server-side, so they're fully indexable by search engines.
⚠️ Weglot's JavaScript snippet integration doesn’t provide SEO benefits. Client-side translation isn't crawlable. If SEO is important, the reverse proxy setup is required.
A secondary benefit is build time. Weglot serves translated pages from its own CDN rather than generating them at build time. Adding 10 languages to a Weglot-powered site has no effect on how long next build takes.
The right choice depends on what you're optimizing for.
If you're building on the App Router and starting fresh, next-intl is the default pick. It covers routing, translations, TypeScript autocompletion, and RSC support in a single package.
If you're on the Pages Router or already have i18next in your stack, react-i18next is the path of least resistance.
If multilingual SEO and ongoing translation management are the priority, neither library solves the full problem. You'll still need to implement hreflang, sitemaps, and a workflow for keeping translations current as content changes. Weglot's reverse proxy handles all of that automatically.
Weglot and a component-level library aren't mutually exclusive. Weglot operates at the HTML and proxy layer, so it can coexist with whatever library your components use.
If that approach fits your situation, check out Weglot's JavaScript integration page as a starting point and try our 14-day free trial!
The best way to understand the power of Weglot is to see it for yourself. Test it for free and without any engagement.
A demo website is available in your dashboard if you’re not ready to connect your website yet.