CreativeCape

Multi-Language Next.js with next-intl (Without Killing Static)

Locale routing, keeping pages static, where translations should live, and the hreflang details most sites get wrong.

July 11, 2026·4 min read

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: /about and /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 URLs

  • A 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.DateTimeFormat and Intl.NumberFormat with 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.


→ Web Application Development

Tagged with
#next.js#i18n#next-intl#localization#app router

Found this useful? Share it.

Keep Reading

Related articles

Booking Q2 2026 Projects

Ready to Build Something Great?

From idea to launch — let our senior engineers build, ship and scale your next product. No commitment, just a conversation.

Senior Engineers
On-Time Delivery
Enterprise-Grade
Free Consultation

Free 30-min discovery call

Talk to a senior engineer — not a salesperson.

We'll review your goals, suggest the leanest path forward, and send a clear proposal within 24 hours.

24h

Response Time

100+

Projects Delivered

No commitment · No automated bots · Fully transparent