Adding internationalization to a Next.js App Router project is the kind of work that looks trivial for the first locale and gets expensive around the third. Most guides cover the happy path: install next-intl, add a middleware, drop translations into messages/*.json, done.
The happy path is fine. The production path has six specific rakes you'll step on. I've stepped on each one shipping tri-lingual sites (EN/RU/UK), and here's how each one manifests and how I avoid it now.
1. The middleware redirect loop
What breaks: You navigate to / and get infinite redirects. Or /en redirects to / redirects to /en. Or only logged-out users see the loop.
Why: The next-intl middleware tries to attach a locale to every request. If your other middleware (auth, A/B testing, custom redirects) also rewrites or redirects, and they don't compose cleanly, you get a loop.
The classic case: an auth middleware that redirects unauthenticated users from / to /login, which is locale-prefixed, which triggers the i18n middleware, which sets a cookie, which changes what / means on the next request, which retriggers the auth middleware.
Fix: Chain middlewares explicitly instead of running them in parallel.
// middleware.ts
import createIntlMiddleware from 'next-intl/middleware'
import { authMiddleware } from './lib/auth'
const intlMiddleware = createIntlMiddleware({
locales: ['en', 'ru', 'uk'],
defaultLocale: 'en',
localePrefix: 'as-needed',
})
export default async function middleware(request) {
// Run i18n first to normalize the URL
const intlResponse = intlMiddleware(request)
// If i18n wants to redirect, let it — don't auth-check first
if (intlResponse.status === 307 || intlResponse.status === 302) {
return intlResponse
}
// Now auth on the normalized URL
return authMiddleware(request) ?? intlResponse
}
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
}
The order matters. i18n goes first to normalize the URL. Auth runs on the normalized URL. Anything that might redirect runs after i18n so redirects land on correctly-prefixed paths.
2. Canonical tags and hreflang — the SEO double-crossing
What breaks: Google indexes /about and /en/about as separate pages with duplicate content. Your ranking for both is worse than either would have been alone.
Why: With localePrefix: 'as-needed', the default locale doesn't have a prefix. So /about and /en/about render identical content but have different URLs. Without explicit canonical/hreflang, Google sees two versions of the same page.
Fix: Emit <link rel="canonical"> that points at the prefix-less version, and <link rel="alternate" hreflang="X"> for every locale.
// app/[locale]/layout.tsx
export async function generateMetadata({ params }) {
const { locale } = await params
const path = '' // or derive from your route
return {
alternates: {
canonical: `https://dimaver6.com${path}`,
languages: {
en: `https://dimaver6.com${path}`,
ru: `https://dimaver6.com/ru${path}`,
uk: `https://dimaver6.com/uk${path}`,
'x-default': `https://dimaver6.com${path}`,
},
},
}
}
Three subtleties:
x-defaulttells Google what to show when locale is ambiguous (search from a country you don't have a locale for). Point it at the default language.- hreflang is reciprocal. If EN links to RU, RU must link back to EN. Asymmetric hreflang is ignored by Google.
- Canonical points at the URL you want indexed. If you want
/aboutand not/en/about, canonical from both points at/about.
Test with Google Search Console's URL Inspection tool. If the "user-declared canonical" and "Google-selected canonical" disagree, you have a bug.
3. The RSC serialization edge that eats translations silently
What breaks: A server component renders a translation. It works in dev. In production, the translation renders blank or as the key itself. You don't see an error; the page just ships broken.
Why: This one bit me hard. When you call useTranslations() in a client component, it works fine. When you call getTranslations() in a server component and pass the result as a prop to a client component, the function-return (a namespace object) can fail to serialize across the RSC boundary if it carries functions instead of strings.
You end up with a prop that looks right in dev (where hydration fills in the gaps) but is effectively empty in the production server-rendered HTML.
Fix: Materialize the strings you need on the server, pass plain strings to the client component.
// Server component — DO THIS
import { getTranslations } from 'next-intl/server'
export default async function Page() {
const t = await getTranslations('hero')
return (
<ClientHero
title={t('title')} // plain string, serializes cleanly
subtitle={t('subtitle')} // plain string
ctaLabel={t('cta')} // plain string
/>
)
}
// NOT this — passing `t` itself across the boundary is fragile
export default async function Page() {
const t = await getTranslations('hero')
return <ClientHero t={t} /> // don't do this
}
If you need a client component to call t(...) dynamically (e.g., with a variable key chosen from user input), use NextIntlClientProvider at a higher level and call useTranslations() on the client side — don't try to pass the translator function across the boundary.
4. Dynamic route params and notFound() for unknown locales
What breaks: Someone types /fr/about in their URL. Your app doesn't support French. Instead of 404'ing cleanly, it renders garbled HTML because the locale isn't valid but the page tries to render anyway.
Why: Next's [locale] catch is literal — it'll catch anything in that slot, including locales you don't support. If you don't gate early, you render with undefined translations.
Fix: Validate at the layout level and notFound() immediately.
// app/[locale]/layout.tsx
import { notFound } from 'next/navigation'
const SUPPORTED_LOCALES = ['en', 'ru', 'uk'] as const
type Locale = (typeof SUPPORTED_LOCALES)[number]
function isSupportedLocale(locale: string): locale is Locale {
return SUPPORTED_LOCALES.includes(locale as Locale)
}
export default async function LocaleLayout({ children, params }) {
const { locale } = await params
if (!isSupportedLocale(locale)) notFound()
// ...
}
Bonus: generateStaticParams exports the valid set, Next caches them, and unknown locales get a 404 at the edge instead of waking up your server.
export function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }))
}
5. Sitemap + robots per locale
What breaks: Your sitemap has English URLs only. Google doesn't know to crawl Russian and Ukrainian. Your i18n work doesn't show up in Search Console.
Why: Default Next.js sitemap generators produce the route tree from the file system — which gives you /[locale]/about once, not /en/about, /ru/about, /uk/about.
Fix: Expand locales at sitemap-generation time.
// app/sitemap.ts
const LOCALES = ['en', 'ru', 'uk'] as const
const ROUTES = ['', '/blog', '/contact']
export default function sitemap() {
const entries = []
for (const route of ROUTES) {
for (const locale of LOCALES) {
const localePath = locale === 'en' ? route : `/${locale}${route}`
entries.push({
url: `https://dimaver6.com${localePath}`,
lastModified: getLastModified(route), // stable, not new Date()
alternates: {
languages: {
en: `https://dimaver6.com${route}`,
ru: `https://dimaver6.com/ru${route}`,
uk: `https://dimaver6.com/uk${route}`,
},
},
})
}
}
return entries
}
Two things:
lastModifiedshould be stable. Returningnew Date()changes every request and defeats sitemap caching. Use git commit dates or content mtime.alternates.languagesin the sitemap echoes the hreflang tags. Google cross-checks.
6. Message file size and the client bundle
What breaks: Your bundle inspector shows 90KB of translation JSON in the client bundle for every locale, when you only serve one locale per user session.
Why: Naive setups import all messages/*.json statically and let the router pick one at runtime. The bundler can't tree-shake because the picking happens at runtime.
Fix: Dynamic import keyed by locale, picked at build time per route.
// i18n/request.ts
import { getRequestConfig } from 'next-intl/server'
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default,
}))
The template literal is load-bearing. await import('../messages/en.json') would statically bundle en.json only. await import(\../messages/$.json`)` with a locale variable tells Next to split per locale and include only the matching one in each per-locale static bundle.
I wrote more about this in the Lighthouse 98 post — per-locale splitting was one of the bigger bundle-size wins there.
The i18n checklist I run before launch
Ship none of the above and you'll have a working site, but you'll be bleeding SEO and possibly users. Before I mark any multi-lingual site "done":
- Middleware composes cleanly — no redirect loops in auth + i18n combination
- Canonical and hreflang emitted on every page, reciprocal, with x-default
- Server components materialize strings; no
tpassed across RSC boundary - Unknown locales →
notFound()at layout level, not at page level - Sitemap expands every route × every locale with stable
lastModified - Dynamic imports for message files, verified in the bundle analyzer
- Google Search Console verified for each locale subpath
- Hreflang spot-checked with Screaming Frog or a similar crawler
That checklist is 10 minutes to run and catches the things that would otherwise leak to prod and stay there for months before anyone notices.
The meta-lesson
i18n is the feature that looks cheapest in the PR description and costs the most across six months. The first locale is a day. The second locale surfaces the middleware and canonical gotchas. The third locale surfaces the sitemap and RSC serialization gotchas. By the time you're at four, you've built the scaffolding and each new locale is a day again.
Build the scaffolding before the second locale. It'll look like overengineering for one-locale work. It pays for itself the moment you add a second.
If you're shipping an i18n project and want a production audit before you go live, I do one-off reviews (€500, 3-day turnaround, written report with every gotcha above checked). Contact.