/articles/nextjs-localization-patternsBack to articles
Next.js8 min

A clean localization pattern for Next.js portfolio and product routes

A route-first localization architecture for Next.js that keeps dictionaries, data fetching, metadata, navigation, API behavior, fallbacks, and migration rules aligned.

Published
2026-06-12
Read time
8 min

Localization stays manageable when locale is treated as part of the request contract rather than a late string-replacement pass. In Next.js, the route can establish that contract once and let server rendering, metadata, links, and APIs follow it consistently.

Make locale visible in the route

For a portfolio or product site with indexable pages, use an explicit locale segment such as /{lang}/articles/{slug}. The URL then identifies both resource and language. It can be shared, cached, logged, rendered on the server, and indexed without consulting a browser-only preference.

Keep the supported set small and typed. In this portfolio, en and th are the supported locales and en is the default. The root route redirects to that default, while the localized layout validates the segment and returns not found for unsupported values. Static parameters are generated from the same locale list. One source of truth prevents routing, rendering, and build-time generation from drifting apart.

Keep dictionaries server-only and typed

Put interface copy in one dictionary per locale, with identical keys and a shared TypeScript shape. Load only the selected dictionary. The implementation here imports en.json or th.json through a locale-keyed loader, marks the module server-only, and wraps lookup in React's request cache. Components receive the dictionary or the smallest relevant slice as props.

Do not force every kind of content into the same dictionary. Navigation labels and form messages belong there; published articles and product records usually belong in a content store with translations attached to the same canonical record. The portfolio backend keeps one article slug and selects a requested translation for its title, summary, lead, reading time, and Markdown body. This preserves a stable product identity while allowing prose to differ naturally across languages.

Draw the server and client boundary deliberately

Resolve the locale, load dictionaries, fetch localized content, and create metadata in Server Components whenever possible. Send Client Components the locale and already-selected copy they actually need. A contact form, for example, can receive its localized labels and include locale as a hidden field without gaining access to every dictionary.

Avoid importing server dictionary loaders into a client component. Besides increasing the bundle, that creates two sources of language state: the route on the server and client state after hydration. If a language switch changes the URL, navigation naturally requests a new server tree. Client state is still appropriate for ephemeral interaction, but it should not decide what language the current route means.

Localize metadata as part of the page

Visible text and metadata must come from the same locale. Generate title, description, canonical URL, Open Graph locale, and language alternates inside the localized route. For an article, use the localized title and summary while keeping the canonical slug stable.

This repository emits canonical paths such as /en/articles/{slug}, language alternates for English and Thai, and an x-default pointing to English. The layout also sets the document's lang attribute and maps locales to Open Graph values such as en_US and th_TH. These details support accessibility and discovery, but they also prevent a practical bug: a Thai page accidentally sharing English preview copy.

Build locale-aware links, not string accidents

Every internal link should be produced from a locale-aware helper or component. Home, article directory, article detail, legal pages, pagination, and form return paths all need the current locale. Centralizing path construction avoids the familiar bug where one footer link drops the user back to the default language.

A language switch deserves additional care. It should preserve the equivalent resource when that translation exists: /en/articles/designing-systems should become /th/articles/designing-systems, not merely /th. Preserve query parameters and safe fragments when they still make sense. For routes whose identifiers differ by locale, maintain an explicit mapping; do not translate path strings heuristically.

Define fallbacks instead of discovering them accidentally

There are at least three separate fallback decisions. The route fallback decides what happens at /. The dictionary fallback decides whether missing interface copy is a build error or falls back to a base language. The content fallback decides what an API returns when a translation is missing. These policies do not have to be identical, but they must be explicit.

The backend currently prefers the requested article translation, then English, then the first available translation. That is a valid availability-first policy, but the response should make such behavior observable if consumers need to label the displayed language. For legal, regulated, or sensitive product content, a strict not-found response may be safer than silently showing another language.

Never catch every fetch error and present it as “no translation.” A missing record, an unavailable API, malformed data, and an unsupported locale are different states. This frontend uses a request timeout, treats 404 as absence, throws on other unsuccessful responses, and validates returned fields. Keeping those distinctions gives the UI a chance to respond honestly.

Make the API localization contract boring

Choose one convention, such as ?lang=th, and use it across list and detail endpoints. Validate the locale at the edge, select the translation in the service layer, and return one localized view rather than leaking every translation to public clients. Keep canonical fields such as slug, category, status, and publication date separate from translated fields.

On writes, validate translations independently and require the locales needed for publication. The backend's article validation requires both English and Thai, trims required fields, restricts status, normalizes slugs, bounds Markdown content, and rejects raw HTML or unsafe URL schemes. This is a stronger contract than relying on the admin interface to submit clean data.

If clients need to know whether a fallback occurred, include a resolved locale field. Do not infer it from the title. Also include locale in cache keys and invalidation strategy; two language responses for one slug are different representations.

Test the contract at every layer

Add unit tests for locale guards, path helpers, dictionary parity, API translation selection, and fallback order. Add route tests for supported and unsupported locale segments, localized metadata, canonical and alternate links, and not-found articles. Render representative pages in both languages and assert that navigation stays in that language.

Test client boundaries too: forms should submit locale, validation errors should use matching copy, and switching language should preserve the current resource. Build-time tests should generate every supported locale. A small crawl over known routes can catch unprefixed internal links before deployment.

Migrate without rewriting everything

Start by inventorying public routes and separating canonical data from visible copy. Introduce the locale type, supported list, and path helper. Add the localized layout and redirect old root routes to the chosen default. Move shared interface copy into typed dictionaries while keeping markup unchanged. Then make data endpoints accept locale and migrate content records behind the same slugs.

After both route trees work, add localized metadata and alternates, update internal links, and test parity. Keep redirects from old URLs during the transition, and avoid changing slugs and language structure in the same release unless necessary. Finally, remove duplicated pages and temporary fallback code only after logs and tests show the localized routes are serving real traffic correctly.

The clean pattern is not a particular library. It is a chain of explicit decisions: the route declares locale, the server resolves content, the API returns one representation, metadata agrees with the page, and links preserve context. When those contracts line up, adding a language becomes a content and verification task rather than a second application.