Adding languages to a Next.js site is mostly mechanical. The part that hurts is doing it without turning a fast static site into a fully dynamic one.
Locale routing and localePrefix
next-intl middleware handles locale detection and routing:
const intl = createMiddleware({
locales: ["en", "es", "fr", "de", "ar", "ta"],
defaultLocale: "en",
localePrefix: "as-needed",
});
localePrefix is the decision that shapes everything:
always— every locale is prefixed, including the default:/en/about,/ta/about. Most predictable; changes all your existing URLs.as-needed— the default locale is unprefixed:/aboutand/ta/about. Preserves existing URLs and avoids a redirect on the most common path.never— locale is stored, not in the URL. Bad for SEO; search engines cannot index each language separately. Avoid unless you have a specific reason.
We use as-needed. The trade-off to remember: the default locale has no prefix, so every canonical, sitemap entry and internal link for English must omit /en. Getting this wrong creates two URLs for the same content.
Keeping pages static
The most common i18n regression: the whole site becomes dynamic.
It happens because something in the render path reads request headers. getMessages() and getLocale() in a root layout can do exactly that, and once a layout is dynamic, every page under it is too. Static generation, full-route caching and CDN caching all quietly stop applying.
Options, roughly in order of preference:
Pass the locale down from params. It is already in the route segment, so use it rather than inferring it from headers.
Keep translation loading at the leaves. Only the components that need messages should pull them, so one shared layout does not force the whole tree dynamic.
Use generateStaticParams to pre-render each locale:
export function generateStaticParams() {
return ["en", "es", "fr", "de", "ar", "ta"].map((locale) => ({ locale }));
}
After any i18n change, check the build output. Next prints the rendering strategy per route; if routes you expect to be static are marked dynamic, something in the tree is reading request data. That table is the fastest diagnostic available.
Where translations live
Three options, with real trade-offs:
In the repo as JSON. Simplest, version-controlled, type-safe. Every copy change needs a deploy — fine for developer-managed copy, painful when marketing owns it.
In the database. Editable through an admin UI, no deploy to change a string. Adds a query to the render path, so cache aggressively.
In object storage (S3/R2) behind a CDN. Editable without a deploy, cheap and fast to read, no database load. This is what we use: an admin screen writes a JSON file per locale to R2; the app reads it through the CDN. The default language falls back to keys, so a missing translation is visible rather than blank.
Whichever you pick, decide what happens when a key is missing: fall back to the default locale (good for users) or render the key (good for spotting gaps). Silently rendering nothing is the wrong answer.
hreflang and canonicals
Two separate jobs, often conflated:
canonical — the definitive URL for this page, in this language
hreflang — the equivalents in other languages
alternates: {
canonical: "/about", // default locale, unprefixed
languages: { "ta": "/ta/about", "es": "/es/about" },
}
Rules that matter: hreflang must be reciprocal — if English points to Tamil, Tamil must point back, or Google ignores both. Include a self-reference. Use x-default for the fallback. And every hreflang URL must return 200 — pointing at a redirect or a 404 invalidates the set.
RTL support
Arabic needs more than a translated string file.
Set direction from the locale on the html element:
<html lang={locale} dir={locale === "ar" ? "rtl" : "ltr"}>
Then use logical CSS properties instead of physical ones: margin-inline-start rather than margin-left, padding-inline-end rather than padding-right. In Tailwind, ms-4/me-4 instead of ml-4/mr-4, and text-start instead of text-left. Written this way, RTL mostly works without a separate stylesheet.
Directional icons — arrows, chevrons, back buttons — still need flipping. Numbers, dates and code blocks stay LTR inside RTL text.
Pitfalls
Forgetting the default locale is unprefixed with
as-needed— duplicate URLsA dynamic root layout silently de-optimising every route
Non-reciprocal hreflang — ignored entirely
Translating the slug but not redirecting the old one — broken links and lost rankings
Physical CSS properties — an RTL layout that is subtly wrong everywhere
Assuming text length is stable — German runs long, Tamil wraps differently; fixed-width buttons break
Hardcoded date and number formats — use
Intl.DateTimeFormatandIntl.NumberFormatwith the active locale
Add a second language early, even if you only half-populate it. Retrofitting i18n into a codebase that assumed one language is dramatically more expensive than building with two from the start.