Skip to content

A Multilingual Next.js Site, End to End

Routing, message loading, RTL, metadata, hreflang, sitemaps and the parts that only break in the fourth language. The architecture we ship, with the decisions that are hard to reverse marked.

13 min read

Most multilingual Next.js sites are built twice. The first version routes by locale and translates the strings, ships, and then spends six months discovering that the sitemap lists one language, the Arabic layout is a mirrored mess, the metadata is English everywhere, and nobody can add a language without touching forty files.

This is the architecture that avoids the second build. The decisions that are expensive to reverse are marked; the rest you can change on a Tuesday.

The URL shape, which you cannot change later

Three options, and only the first two are defensible:

ShapeExampleVerdict
Subdirectoryexample.com/de/blogDefault. One domain's authority, one deployment.
Subdomainde.example.com/blogOnly if the languages are run by separate teams.
Query parameterexample.com/blog?lang=deNever. Crawlers treat it as one page.

This is the irreversible one. Changing it later means redirecting every URL on the site and waiting for the index to catch up, which takes months. Decide it before the first deploy.

Within the subdirectory shape there is a second decision: does the default language carry a prefix? Unprefixed (/blog for English, /de/blog for German) keeps the shorter URL for the largest audience and is what we ship. Prefixed (/en/blog) is more symmetric and easier to reason about. Either works; mixing them does not.

// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
 
export const routing = defineRouting({
  locales: ['en', 'ar', 'de', 'es'],
  defaultLocale: 'en',
  // 'as-needed' leaves the default locale unprefixed. 'always' prefixes
  // every locale. This is the setting that decides your URL shape.
  localePrefix: 'as-needed',
});

Routing without a redirect on every request

The locale segment is a dynamic segment, and the whole tree lives under it:

app/
  [locale]/
    layout.tsx
    page.tsx
    blog/
      page.tsx
      [slug]/page.tsx

Generate the static params so every locale is prerendered rather than rendered on demand:

// app/[locale]/page.tsx
import { routing } from '@/i18n/routing';
import { setRequestLocale } from 'next-intl/server';
 
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}
 
export default async function Page({ params }) {
  const { locale } = await params;
  // Without this the page opts into dynamic rendering, because reading the
  // locale from the request is a request-time read. One line, and the
  // difference between a static route and a server render per visitor.
  setRequestLocale(locale);
 
  // ...
}

setRequestLocale is the line people omit, and the symptom is a build output where every localised route shows ƒ instead of . It costs you the CDN on every page of the site.

Messages, and not shipping all of them

Load one locale's messages, not four. The naive import of a messages index puts every language in every bundle.

// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
 
export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = routing.locales.includes(requested) ? requested : routing.defaultLocale;
 
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

The dynamic import is what keeps the other three out of the payload. Check it: if de.json appears in the English route's chunk, the import got hoisted somewhere.

Keep the message files structurally identical. A missing key in one language is a runtime error in production and nowhere else, so it is worth a test:

// A key present in English must exist in every locale.
const flatten = (obj, prefix = '') =>
  Object.entries(obj).flatMap(([k, v]) =>
    typeof v === 'object' && !Array.isArray(v)
      ? flatten(v, `${prefix}${k}.`)
      : [`${prefix}${k}`],
  );
 
for (const locale of ['ar', 'de', 'es']) {
  const missing = flatten(en).filter((k) => !flatten(messages[locale]).includes(k));
  if (missing.length) throw new Error(`${locale} is missing: ${missing.join(', ')}`);
}

RTL is a layout problem, not a translation problem

Arabic is where a Latin-only layout falls apart, and the fix is not a stylesheet per direction. It is writing the layout in logical properties from the start, so direction is data rather than a branch.

<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>

Then every horizontal value is logical:

PhysicalLogicalWhat it means
ml-4ms-4margin at the start of the line
pr-6pe-6padding at the end of the line
left-0start-0inset at the start
text-lefttext-startaligned to the start

Written that way, the Arabic layout mirrors itself and there is no second stylesheet to keep in sync. Three things stay physical on purpose: code blocks, which are LTR in every language; icons that encode direction, such as a "next" arrow, which need an explicit flip; and numbers, which are not mirrored.

[dir='rtl'] .rtl-flip {
  transform: scaleX(-1);
}

One more that catches people: Arabic is a connected script, and the negative letter-spacing that makes a Latin display heading look tight pulls the joins apart and is simply wrong for it. Stop the tracking at the language boundary.

Switching language without losing the page

The language switcher that sends everyone to the home page is the most common bug in this whole architecture, and it is the one visitors notice. Someone reading an article in English clicks German and lands on the German front page, having lost what they were reading.

Switch on the current path, not on the root:

'use client';
 
import { usePathname, useRouter } from '@/i18n/navigation';
 
export function LocaleSwitcher({ current }: { current: Locale }) {
  // This pathname is locale-stripped: on /de/blog/x it returns /blog/x, so
  // the same value works for every target locale.
  const pathname = usePathname();
  const router = useRouter();
 
  return routing.locales.map((locale) => (
    <button
      key={locale}
      lang={locale}
      aria-current={locale === current ? 'true' : undefined}
      onClick={() => router.replace(pathname, { locale })}
    >
      {names[locale]}
    </button>
  ));
}

One caveat worth knowing before a visitor finds it: a page that does not exist in the target language will 404. Either hide the locales a page has no translation for, or route them to the translated parent - but decide, because the default is a dead end.

Metadata, per page, per language

Every page needs its own title, description, canonical and alternates, in its own language. Generated from one helper, or they drift:

// lib/seo.ts
export function buildMetadata({ locale, title, description, path, availableLocales }) {
  const url = absoluteUrl(locale, path);
 
  return {
    title,
    description,
    alternates: {
      canonical: url,
      languages: {
        // Only the locales that actually have this page. Pointing
        // hreflang="de" at an English page is worse than no hreflang.
        ...Object.fromEntries(
          availableLocales.map((l) => [l, absoluteUrl(l, path)]),
        ),
        'x-default': absoluteUrl(defaultLocale, path),
      },
    },
    openGraph: { url, title, description, locale },
  };
}

The rule that matters: hreflang must reflect what exists. A cluster that advertises four translations when two of them are the English page is a cluster search engines learn to distrust. Derive the list from the filesystem, not from a constant. The reasoning is worked through in correct hreflang in Next.js, generated not maintained.

The sitemap, from the same source

One sitemap, every locale, with the alternates on each entry:

// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
  return routes.flatMap((route) =>
    localesFor(route).map((locale) => ({
      url: absoluteUrl(locale, route.path),
      lastModified: route.updated,
      alternates: {
        languages: Object.fromEntries(
          localesFor(route).map((l) => [l, absoluteUrl(l, route.path)]),
        ),
      },
    })),
  );
}

routes comes from the same function the pages use. A sitemap built from a hand-maintained list disagrees with the router within a month: canonicals and sitemaps that cannot drift.

What we check before a language ships

Not a checklist for the developer's memory - scripts that fail a build:

  1. Every message key exists in every locale. A missing key is a production crash in one language only.
  2. No horizontal overflow, at 320px, in every language. German compounds are a third longer than English and there is no dictionary to hyphenate them. This is the single most common way a multilingual site breaks.
  3. No mid-word breaks. The fallback that stops the overflow will split a word rather than widen the page, which looks like a bug because it is one.
  4. Every canonical points at its own address, in every locale, and the prefixed form of the default locale redirects.
  5. hreflang clusters are reciprocal. If the German page lists Spanish, the Spanish page must list German.

The first two catch the errors that ship. The last three catch the errors that cost you rankings quietly, six weeks later, with nothing in the logs.

The part that is not engineering

A translated string is not a translated page. Search intent differs by market: the German phrase a buyer types is not a translation of the English one, it is a different phrase with a different volume. Translating your keyword research gives you pages that rank for nothing in three languages.

Budget for that separately, and treat the architecture above as what makes it possible to act on the answer rather than as the answer itself.

Back to all articles