Multilingual Next.js: next-intl, hreflang, and RTL#
My three production sites have nothing in common topically — a developer portfolio, a calculator for runners, and a health service about the effect of weather on the body — yet they share the exact same i18n architecture. dodecaidr.pro speaks five languages (ru, en, es, ja, zh-CN), runcalculator.pro also speaks five, and meteohealth.pro speaks six, including Arabic with an RTL layout. All three are built on the Next.js App Router with next-intl, and all three hit the same wall at some point: broken hreflang, translations silently missing from parity tests, and a layout that falls apart in Arabic.
This is not a from-scratch next-intl tutorial. It's a breakdown of the concrete architectural decisions I reuse across all three projects, with code drawn from runcalculator.pro and meteohealth.pro, and a list of mistakes I either made myself or deliberately avoided.
Three production sites, one shared headache#
When a site lives in a single language, i18n is a library for translated strings. Once you're past five languages, i18n is an architectural decision that touches routing, SEO, content testing, and layout. Across all three projects I converged on the same set of patterns:
localePrefix: 'always'— every URL keeps its locale prefix (/ru/...,/en/...), with no "bare" path for the default language. This simplifies caching, logs, and hreflang, but it requires an explicitx-default.- A middleware/proxy that resolves the locale before rendering — next-intl determines the locale from the URL, a cookie, and
Accept-Language, before the page even starts assembling. - hreflang and
x-defaultgenerated from a single source — not hand-written on every page, but produced by a function that knows the full locale list. - Localized content in parallel folders —
content/<type>/<locale>/..., rather than a JSON dictionary tens of thousands of lines long. - A locale parity test — a script that fails CI if a new article is missing one of the five or six locale versions.
- RTL layout verification — not relevant to all three projects, but unavoidable wherever Arabic is present (meteohealth.pro).
Here's what each of those looks like in code.
A single source of truth: locale configuration#
The first mistake I've seen in other people's projects (and made once myself) is a locale list duplicated across the middleware, the next-intl config, and the language switcher component. Add a locale, and it shows up in the switcher but doesn't work in the middleware, because that's a separate array.
The fix is one file that everything else imports from:
// lib/i18n/config.ts
export const locales = ['ru', 'en', 'es', 'ja', 'zh-CN'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'ru'
export const i18nConfig = {
locales,
defaultLocale,
localePrefix: 'always' as const, // every URL keeps its locale prefix
}
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale)
}
export function getValidLocale(locale: string | undefined): Locale {
if (!locale) return defaultLocale
return isValidLocale(locale) ? locale : defaultLocale
}The middleware, the sitemap generator, the hreflang generator, and the MDX content loader all import locales and defaultLocale from here. Adding a new locale means editing one array plus adding translation and content files — no routing logic needs to be touched by hand.
next-intl v4: requestLocale and message loading#
In next-intl v4, the locale passed to getRequestConfig arrives asynchronously, as a Promise, rather than synchronously. That change broke some codebases on upgrade from v3 if they didn't check the type. The current pattern looks like this:
// i18n.ts
import { notFound } from 'next/navigation'
import { getRequestConfig } from 'next-intl/server'
import { locales, defaultLocale, isValidLocale, getValidLocale, type Locale } from './lib/i18n/config'
export default getRequestConfig(async ({ requestLocale }) => {
// next-intl v4: locale arrives as a Promise
const requested = await requestLocale
if (requested && !isValidLocale(requested)) {
notFound()
}
const locale = getValidLocale(requested)
const [common, marketing] = await Promise.all([
import(`./locales/${locale}/common.json`),
import(`./locales/${locale}/marketing.json`),
])
return {
locale,
messages: {
common: common.default,
marketing: marketing.default,
},
timeZone: getTimeZone(locale),
}
})
function getTimeZone(locale: Locale): string {
const timeZones: Record<Locale, string> = {
ru: 'Europe/Moscow',
en: 'America/New_York',
es: 'Europe/Madrid',
ja: 'Asia/Tokyo',
'zh-CN': 'Asia/Shanghai',
}
return timeZones[locale] ?? 'UTC'
}Two details rarely mentioned in tutorials. First, notFound() should only fire when a locale was actually passed and is invalid — not when it's missing — otherwise any path where the locale hasn't resolved yet will break. Second, the time zone isn't decorative: skip it, and Intl.DateTimeFormat plus next-intl's date formatters fall back to the server's TZ, so an article's published date on the Japanese version of the site can render as yesterday.
proxy.ts instead of middleware.ts: what changed in Next.js 16#
Next.js 16 renamed middleware.ts to proxy.ts — the file and the exported function have a new name, but the contract with next-intl hasn't changed: createMiddleware from next-intl/middleware still wraps into a single handler.
// proxy.ts — renamed from middleware.ts in Next.js 16
import createMiddleware from 'next-intl/middleware'
import { NextRequest } from 'next/server'
import { locales, defaultLocale } from './lib/i18n/config'
const intlMiddleware = createMiddleware({
locales,
defaultLocale,
localePrefix: 'always',
})
export default function proxy(request: NextRequest) {
return intlMiddleware(request)
}
export const config = {
// skip /api, /_next and static files with a dot in the path
matcher: ['/((?!api|_next|.*\\..*).*)'],
}In practice, this is a convenient place to add end-to-end request logging (method, path, status, duration) — it doesn't interfere with i18n redirects as long as it's called after intlMiddleware(request), not instead of it.
hreflang and x-default: where 75% of sites break#
There's a stat (Ahrefs) that roughly 75% of sites with international versions get hreflang wrong: missing return links (page A links to B, but not the other way around), broken URLs in the tags, and — the most common one — a canonical tag that points to the English page on every locale version. That last mistake effectively tells the search engine "ignore the other locales," which is how site owners zero out their own i18n work.
To avoid hand-typing hreflang on every page, I generate it with a single function and reuse it in both generateMetadata and the sitemap:
// Generates alternates for both metadata and sitemap entries
function generateAlternates(route: string) {
const alternates: Record<string, string> = {}
for (const locale of locales) {
alternates[locale] = route
? `${baseUrl}/${locale}/${route}`
: `${baseUrl}/${locale}`
}
// With localePrefix: 'always' there is no locale-neutral URL,
// so x-default has to point somewhere — the default locale is the pragmatic choice
alternates['x-default'] = route
? `${baseUrl}/${defaultLocale}/${route}`
: `${baseUrl}/${defaultLocale}`
return alternates
}
// app/[locale]/articles/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params
return {
alternates: {
canonical: `${baseUrl}/${locale}/articles/${slug}`,
languages: generateAlternates(`articles/${slug}`),
},
}
}A subtlety that's easy to miss: x-default should appear exactly once, pointing to the page for users whose language doesn't match any of your locales — usually the homepage or a language-selector page. With localePrefix: 'always', a locale-neutral URL simply doesn't exist, so x-default points to the default locale as a pragmatic compromise, not a "correct" answer — worth documenting explicitly in the codebase, so nobody "fixes" it six months from now into a nonexistent root path.
Every page also needs to reference itself (self-referencing hreflang) — without that, a search engine is entitled to ignore the entire set of alternate tags on the page.
Localized MDX content in parallel folders, validated with Zod, checked by a parity test#
A JSON dictionary of UI translations is a fine solution for buttons and labels, but not for content: articles, project descriptions, privacy policies. Keeping long-form text in JSON means losing formatting, code blocks, and the ability to just open a file in an editor and write. So content lives in parallel per-locale folders instead:
content/articles/
├── ru/general/nextjs-i18n-next-intl.mdx
├── en/general/nextjs-i18n-next-intl.mdx
├── es/general/nextjs-i18n-next-intl.mdx
├── ja/general/nextjs-i18n-next-intl.mdx
└── zh-CN/general/nextjs-i18n-next-intl.mdxEvery file's frontmatter is validated against a Zod schema at read time: required fields, allowed category values, dates in YYYY-MM-DD format — if a translator (or I, at 11pm) forgets a field or typos a category, the build fails with a clear Zod error instead of silently rendering an empty article card in production.
A separate parity-test script checks that every slug has a file in every locale, and that the JSON translation dictionaries have matching keys (not values) across languages. Without that test, the typical failure mode looks like this: an article ships in Russian, the PR merges, and a month later it turns out the Japanese version of the site is still showing a 404 — or worse, an old title pulled from a shared default.
RTL isn't just dir="rtl": lessons from meteohealth.pro#
Of the three projects, only meteohealth.pro speaks Arabic, and that's exactly where RTL bugs showed up that never appear in ru/en/es/ja/zh-CN — none of which mirror the layout. dir="rtl" on <html> covers roughly 60% of the work: text aligns and flows the right way. The remaining 40% is icons that need to flip (a "forward" arrow can't point backward), inputs that need to accept Arabic digits and text without flipping direction character by character, and layout built on margin-left/padding-right that just ends up on the wrong side in RTL.
// app/[locale]/layout.tsx
import { locales, type Locale } from '@/lib/i18n/config'
const RTL_LOCALES: Locale[] = ['ar'] // meteohealth.pro only
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: Locale }>
}) {
const { locale } = await params
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr'
return (
<html lang={locale} dir={dir}>
<body className="ms-0 pe-4 rtl:pe-0 rtl:ps-4">
{children}
</body>
</html>
)
}Practical takeaways that apply to any project with an RTL locale:
- Use logical CSS properties (
margin-inline-start,padding-inline-end; in Tailwind,ms-/ps-/me-/pe-) instead of physical ones (margin-left/padding-right) — mirroring then happens automatically, without duplicating classes behindrtl:. - For the cases logical properties don't cover (icons, specific transforms), use Tailwind's
rtl:variant, e.g.rtl:rotate-180for arrows. - Mixed content — Arabic text with Latin fragments (brand names, emails, code) — should be wrapped in
<bdi>, otherwise character order at the direction boundary can visually scramble. - RTL can't be eyeballed once before a release. Screenshot comparison of key screens (cards, forms, navigation) in both
ltrandrtlvariants catches regressions that would otherwise only surface in user complaints. - Use real Arabic text in the test environment from day one, not "we'll add translations later." RTL bugs on pseudo-mirrored Latin text and on real Arabic script are different bugs.
Comparing the three projects, and a checklist#
| Project | Locales | RTL | Content | What's unique |
|---|---|---|---|---|
| dodecaidr.pro | 5 (ru, en, es, ja, zh-CN) | no | MDX + Zod schemas, CI parity test | Portfolio and articles are content, not UI strings |
| runcalculator.pro | 5 | no | MDX descriptions and localized calculation formulas | Numeric formats (pace, distance) depend on locale, not just text |
| meteohealth.pro | 6 (+ Arabic) | yes | MDX plus localized screenshots and policies in 5+ languages | The only project where RTL is a daily layout check, not a theory |
If I had to name what carries over unchanged from project to project:
- Locales and the default locale live in one file, not three.
- The middleware/
proxy.tsresolves the locale before rendering and doesn't second-guess next-intl on its own. - hreflang and
x-defaultare generated by a function, not copy-pasted across pages. - Content lives in per-locale MDX, with Zod-validated frontmatter and a CI parity test.
- RTL isn't a one-time
dirtweak — it's logical CSS properties plus a dedicated testing pass whenever Arabic or Hebrew is in the locale list.
All five points sound like common sense written down on paper. In practice, every single one got violated at least once across the three projects before it turned into a rule.



