Abdelrahman

Multi-Language Sites in Next.js: Locale Routing, Typed Dictionaries, and the RTL U-Turn

nextjsi18ntypescriptroutinglocalization

Every tutorial about internationalization in Next.js starts the same way: install a library with a name like next-super-intl-i18n-pro, wrap your app in three providers, sprinkle useTranslations() everywhere, and hope the bundle size fairy is merciful. Meanwhile the actual mechanics — how a URL becomes a locale, where the redirect happens, why your Arabic layout looks like it lost a fight with a mirror — stay hidden inside the library, where you can't learn from them.

This article builds the whole thing by hand: locale-prefixed routing, automatic language detection, type-safe dictionaries, RTL support, and the SEO wiring, in the Next.js App Router with TypeScript. Two tiny utility packages for parsing a header. Zero i18n frameworks. (Libraries are fine, to be clear. But you should be able to explain what they do before you npm install your way past understanding them. That's the rule. I don't make the rules. Okay — this one I made.)

Everything here targets Next.js 16, and that matters more than usual — one of the files in this setup got renamed since most tutorials were written.

Full implementation: github.com/AbdelrahmanMostafa0/multi-language-site-in-nextjs — clone it and follow along, or skip straight to the code.

What we're building

  • /en/about, /ar/about, /fr/about — every page lives under a [lang] segment
  • Automatic detection — a visitor hitting /about gets redirected to their language, via cookie first and Accept-Language second
  • Typed dictionaries — translations where a missing key is a compile error, not a blank spot in production
  • RTL support — Arabic flips the layout, and the layout is fine with that
  • hreflang metadata — so Google knows the three versions are siblings, not duplicates

The plan in one sentence: the URL owns the locale, a proxy fills in the URL when it's missing, and everything else just reads the URL.

Step 1: the locale config (one file, no surprises)

Every decision the rest of the code makes traces back to this file:

ts
// lib/i18n/config.ts
export const locales = ["en", "ar", "fr"] as const;
export type Locale = (typeof locales)[number];
 
export const defaultLocale: Locale = "en";
export const rtlLocales: readonly Locale[] = ["ar"];

The as const is doing the heavy lifting: Locale is now the union "en" | "ar" | "fr", derived from the array instead of maintained next to it. Add "de" to the array and the type updates itself — and, as you'll see, the dictionary map immediately fails to compile until you provide German translations. The compiler becomes your project manager. It's less annoying than a human one and equally impossible to negotiate with.

Step 2: the [lang] segment — the URL owns the locale

Move your routes under a dynamic segment:

txt
app/
  [lang]/
    layout.tsx
    page.tsx
    about/
      page.tsx

The layout validates the locale, sets the <html> attributes, and — critically — tells Next.js to pre-render every locale:

tsx
// app/[lang]/layout.tsx
import { notFound } from "next/navigation";
import { locales, rtlLocales, type Locale } from "@/lib/i18n/config";
 
export function generateStaticParams() {
  return locales.map((lang) => ({ lang }));
}
 
export default async function LangLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  if (!locales.includes(lang as Locale)) notFound();
  const locale = lang as Locale;
 
  return (
    <html lang={locale} dir={rtlLocales.includes(locale) ? "rtl" : "ltr"}>
      <body>{children}</body>
    </html>
  );
}

Three things earning their keep:

  • params is a Promise in current Next.js — await it. If a tutorial destructures it synchronously, check the date on that tutorial.
  • notFound() for junk locales. [lang] happily matches /klingon/about; without the guard, you'd try to load a Klingon dictionary. (We are not translating this site into Klingon. The plural rules alone would take a sprint.)
  • generateStaticParams — the reason a "dynamic" segment doesn't cost you static rendering. All three locale versions of every page are built ahead of time. The i18n is dynamic; the hosting bill isn't.

The dir attribute is one line here and it's the difference between Arabic looking like a website and Arabic looking like an accident. More on that in the RTL section, where I have feelings to share.

Step 3: detection in proxy.ts — yes, proxy, not middleware

Someone hits /about with no locale prefix. Somebody has to pick a language and redirect — that's a job for the request layer, and here's the part that outdates half the internet: in Next.js 16, middleware.ts was renamed to proxy.ts. Same job — runs before a request completes — new name, and the exported function is called proxy now. If you're copy-pasting a middleware.ts from a 2024 blog post into a Next.js 16 app, you're pasting into the before times.

Two micro-packages parse the Accept-Language header properly (it's a weighted list like ar,en;q=0.9 — parse it by hand and you will get the q-values wrong; ask me how I know):

bash
npm i negotiator @formatjs/intl-localematcher
npm i -D @types/negotiator
ts
// proxy.ts (project root — this is middleware.ts's new name in Next 16)
import { NextResponse, type NextRequest } from "next/server";
import Negotiator from "negotiator";
import { match } from "@formatjs/intl-localematcher";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
 
const LOCALE_COOKIE = "locale";
 
function detectLocale(request: NextRequest): Locale {
  // 1. explicit user choice, stored when they used the language switcher
  const cookie = request.cookies.get(LOCALE_COOKIE)?.value;
  if (cookie && locales.includes(cookie as Locale)) return cookie as Locale;
 
  // 2. browser preference, properly weighted
  const languages = new Negotiator({
    headers: {
      "accept-language": request.headers.get("accept-language") ?? "",
    },
  }).languages();
 
  return match(languages, locales, defaultLocale) as Locale;
}
 
export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  const hasLocale = locales.some(
    (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
  );
  if (hasLocale) return; // already prefixed — nothing to do
 
  const url = request.nextUrl.clone();
  url.pathname = `/${detectLocale(request)}${pathname}`;
  return NextResponse.redirect(url);
}
 
export const config = {
  // skip Next internals, API routes, and anything with a file extension
  matcher: ["/((?!_next|api|.*\\..*).*)"],
};

The ordering inside detectLocale is a product decision disguised as an if statement: the cookie wins over the header. A visitor whose browser says French but who clicked "English" in your switcher has told you something. Believe them. Re-detecting from Accept-Language on every visit and yanking them back to French is how you end up with users who associate your site with a tiny spike of dread.

And the matcher: without the .*\\..* exclusion, your proxy will cheerfully redirect /favicon.ico to /en/favicon.ico, which does not exist, which means your favicon 404s in every language simultaneously. Very inclusive. Very broken.

Step 4: dictionaries — where the type safety pays rent

Translations live in plain JSON, one file per locale:

json
// dictionaries/en.json
{
  "home": {
    "title": "I build web things",
    "cta": "See my work"
  },
  "nav": {
    "about": "About",
    "blog": "Blog"
  }
}

Now the trick that makes this better than half the i18n libraries: English is the schema. TypeScript can type a JSON import, so the English file's shape becomes the required shape for every other locale:

ts
// lib/i18n/dictionaries.ts
import "server-only";
import type { Locale } from "./config";
import type en from "@/dictionaries/en.json";
 
export type Dictionary = typeof en;
 
const dictionaries: Record<Locale, () => Promise<Dictionary>> = {
  en: () => import("@/dictionaries/en.json").then((m) => m.default),
  ar: () => import("@/dictionaries/ar.json").then((m) => m.default),
  fr: () => import("@/dictionaries/fr.json").then((m) => m.default),
};
 
export function getDictionary(locale: Locale) {
  return dictionaries[locale]();
}

Walk through what this buys, because it's a lot for fifteen lines:

  • Record<Locale, ...> — add "de" to the locales array and this object fails to compile until a German loader exists. Forgetting to wire a language is now impossible.
  • Promise<Dictionary> — every locale's JSON must match the English shape. Delete nav.blog from ar.json and the build breaks, instead of Arabic visitors getting a nav item that renders as undefined and a vibe of quiet neglect.
  • Dynamic import() — each request loads only its own locale's JSON. English visitors don't download French. Revolutionary concept, rarely implemented.
  • import "server-only" — the tripwire. If any client component tries to import this file, the build fails on the spot instead of shipping every translation you own to every browser. (If you read the multi-step forms post, you know the doctrine by now: one source of truth, everything else derived, the compiler guards the wiring. The doctrine has a third believer. Welcome.)

Using it in a page is exactly as boring as it should be:

tsx
// app/[lang]/page.tsx
import { getDictionary } from "@/lib/i18n/dictionaries";
import type { Locale } from "@/lib/i18n/config";
 
export default async function HomePage({
  params,
}: {
  params: Promise<{ lang: Locale }>;
}) {
  const { lang } = await params;
  const dict = await getDictionary(lang);
 
  return (
    <main>
      <h1>{dict.home.title}</h1>
      <a href={`/${lang}/projects`}>{dict.home.cta}</a>
    </main>
  );
}

dict.home.title is autocompleted and typo-proof — dict.home.titel is a red squiggle, not a production incident discovered by a confused visitor in Marseille.

Client components: pass the slice, not the pantry

Server components can await getDictionary(). Client components can't — and server-only makes sure they don't try. The pattern: the server component picks out the strings a client component needs and hands them over as props.

tsx
// app/[lang]/contact/page.tsx (server)
import { getDictionary } from "@/lib/i18n/dictionaries";
import type { Locale } from "@/lib/i18n/config";
import { ContactForm } from "@/components/contact-form";
 
export default async function ContactPage({
  params,
}: {
  params: Promise<{ lang: Locale }>;
}) {
  const { lang } = await params;
  const dict = await getDictionary(lang);
 
  return <ContactForm dict={dict.contact} />;
}
tsx
// components/contact-form.tsx
"use client";
 
import type { Dictionary } from "@/lib/i18n/dictionaries";
 
export function ContactForm({ dict }: { dict: Dictionary["contact"] }) {
  return <label>{dict.labels.emailLabel}</label>;
}

Dictionary["contact"] types the prop straight off the JSON shape — the client component states exactly which slice it needs, gets exactly that slice, and the other 95% of your translations stay on the server where they weigh nothing. Some setups ship the entire dictionary to the browser in a context provider "for convenience." That's not convenience, that's your ar.json riding along on every English page view like an emotional support payload.

The language switcher is the one honest client component in the system — it needs to know where the user is (usePathname) and to remember their choice (the cookie the proxy reads):

tsx
// components/locale-switcher.tsx
"use client";
 
import Link from "next/link";
import { usePathname } from "next/navigation";
import { locales, type Locale } from "@/lib/i18n/config";
 
export function LocaleSwitcher() {
  const pathname = usePathname(); // e.g. /en/about
 
  function switchTo(locale: Locale) {
    const rest = pathname.split("/").slice(2).join("/");
    return `/${locale}${rest ? `/${rest}` : ""}`;
  }
 
  return (
    <nav>
      {locales.map((locale) => (
        <Link
          key={locale}
          href={switchTo(locale)}
          onClick={() => {
            document.cookie = `locale=${locale}; path=/; max-age=31536000`;
          }}
        >
          {locale.toUpperCase()}
        </Link>
      ))}
    </nav>
  );
}

Swapping the first path segment keeps the visitor on the same page in the new language — /en/about becomes /ar/about, not /ar. Kicking someone back to the homepage because they dared to change languages is a classic. Don't be a classic. The cookie write is what makes the choice stick: next time they arrive at a bare URL, the proxy reads it and skips the header negotiation entirely.

Look back at the manual /${lang}/projects link in step 4's HomePage. Honest work — once. Write it across an eighty-link app and two things go wrong. You'll fat-finger /${lang}/projcts somewhere and not notice. And more insidiously, someone will "tidy" a link down to a plain <Link href="/projects"> because the interpolation looks like clutter — and now that link drops the locale. The proxy catches the bare URL and redirects to a locale (cookie or header), not necessarily the one the visitor is currently reading. Click a link on the Arabic page, land on English, feel quietly betrayed.

The Pages Router used to prefix the locale onto <Link> for you. The App Router handed that job back and didn't leave a note: next/link and useRouter have no idea your URLs carry a locale — to them /projects is already a complete address. So build a <Link> that reads the locale off the URL and prefixes it once, in one place:

tsx
// components/i18n/localized-link.tsx
"use client";
 
import Link from "next/link";
import { useParams } from "next/navigation";
import type { ComponentProps } from "react";
import type { Locale } from "@/lib/i18n/config";
 
type Props = Omit<ComponentProps<typeof Link>, "href"> & { href: string };
 
export function LocalizedLink({ href, ...rest }: Props) {
  const { lang } = useParams<{ lang: Locale }>();
  // prefix internal, root-relative paths only — leave external URLs and #hashes alone
  const to = href.startsWith("/") ? `/${lang}${href}` : href;
  return <Link href={to} {...rest} />;
}

useParams reads the same [lang] segment the pages read — the URL stays the one source of truth, the link just consults it instead of asking you to retype it. Programmatic navigation gets the identical treatment:

ts
// lib/i18n/use-localized-router.ts
"use client";
 
import { useRouter, useParams } from "next/navigation";
import type { Locale } from "@/lib/i18n/config";
 
export function useLocalizedRouter() {
  const router = useRouter();
  const { lang } = useParams<{ lang: Locale }>();
  const withLang = (href: string) =>
    href.startsWith("/") ? `/${lang}${href}` : href;
 
  return {
    ...router,
    push: (href: string) => router.push(withLang(href)),
    replace: (href: string) => router.replace(withLang(href)),
  };
}

Call sites lose the locale entirely — which is the whole point:

tsx
<LocalizedLink href="/projects">{dict.home.cta}</LocalizedLink>;
// renders /en/projects on the English page, /ar/projects on the Arabic one
 
const router = useLocalizedRouter();
router.push("/contact"); // /ar/contact — no ${lang} in sight

Two details earn their keep. The href.startsWith("/") guard is what keeps the wrapper from mangling https://… and #section links — prefix root-relative internal paths, pass everything else through untouched. And useParams is client-only, so this covers client components; your server components already hold lang as a prop (the HomePage above) and can keep interpolating it directly — no hook, no "use client", no cost.

This is exactly what the LocaleSwitcher up there was doing by hand — slicing off the segment, re-prefixing the rest — pulled out into a component you stop thinking about. One place knows how a locale attaches to a URL. Change the scheme, add a locale, move to sub-domains someday: you edit withLang and nothing else. (Say it with me. One source of truth, everything else derived. It's load-bearing now.)

RTL: the U-turn your CSS needs to survive

Arabic doesn't just use different words — the entire page flows right-to-left. The dir="rtl" attribute from step 2 makes the browser mirror the layout: text alignment, flex direction, list markers, all of it. Free. Automatic. Unless your CSS fights it.

Here's the fight: every margin-left, padding-right, and text-align: left in your stylesheet is a hardcoded direction. In RTL mode, that "margin before the icon" is now a margin after the icon, and your neat header turns into abstract art. The fix is CSS logical properties — say what you mean, not which side you mean:

css
/* physical — breaks in RTL */
.card {
  margin-left: 16px;
  text-align: left;
}
 
/* logical — mirrors automatically */
.card {
  margin-inline-start: 16px;
  text-align: start;
}

inline-start means "where this language starts writing" — left in English, right in Arabic, correct in both. Same idea in Tailwind: reach for the logical utilities and drop the physical ones.

tsx
{
  /* physical — breaks in RTL */
}
<div className="ml-4 pr-2 text-left" />;
 
{
  /* logical — mirrors automatically */
}
<div className="ms-4 pe-2 text-start" />;

ms-*/me-* (margin-inline-start/end) and ps-*/pe-* (padding-inline-start/end) map straight onto the logical properties, and text-start/text-end replace text-left/text-right. Modern Tailwind ships these first-class, so the fix is a find-and-replace, not a rewrite. One dir attribute plus logical properties — CSS or utilities — and the U-turn just… works. No rtl.css fork, no [dir="rtl"] .card { margin-right: 16px; margin-left: 0; } overrides multiplying in the dark.

While we're respecting other languages: dates and plurals. Don't concatenate, don't hand-roll — the platform ships Intl and it already speaks everything:

ts
new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(date);
// en: "July 8, 2026" · ar: "٨ يوليو ٢٠٢٦" · fr: "8 juillet 2026"
 
new Intl.PluralRules("ar").select(11); // "many"

Fun fact to end friendships at parties: Arabic has six plural categories — zero, one, two, few, many, other. English speakers write count === 1 ? "file" : "files" and believe they've handled pluralization. Intl.PluralRules knows better, costs zero bytes, and was in your runtime the whole time.

hreflang: telling Google the three sites are one site

Three URLs now serve the same content in three languages. Without a signal, a crawler sees triplicate pages competing with each other. The signal is hreflang alternates, and the Metadata API makes it declarative:

tsx
// app/[lang]/layout.tsx
import type { Metadata } from "next";
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ lang: string }>;
}): Promise<Metadata> {
  const { lang } = await params;
  return {
    alternates: {
      canonical: `/${lang}`,
      languages: {
        en: "/en",
        ar: "/ar",
        fr: "/fr",
        "x-default": "/en",
      },
    },
  };
}

The relative paths resolve against the metadataBase you set once in the root layout — covered in the technical SEO post, which this section is quietly a sequel to. Two rules crawlers actually enforce:

  • x-default names the fallback for languages you don't serve. Without it, a German visitor's search results pick a locale by vibes.
  • hreflang must be reciprocal. The English page points at the Arabic page, and the Arabic page must point back. Miss one direction and Google discards the whole relationship — it's the one area of SEO where ghosting has immediate consequences.

The sitemap speaks the same language (sorry) — each entry declares its alternates:

ts
// app/sitemap.ts — one entry per page, alternates per locale
const routes = pages.flatMap((page) =>
  locales.map((locale) => ({
    url: `${site.url}/${locale}${page.path}`,
    lastModified: page.updated,
    alternates: {
      languages: Object.fromEntries(
        locales.map((l) => [l, `${site.url}/${l}${page.path}`]),
      ),
    },
  })),
);

Gotchas worth remembering

  • It's proxy.ts in Next.js 16, not middleware.ts. Same request-layer job, renamed file, exported function is proxy. Tutorials showing middleware.ts predate the rename.
  • Cookie beats Accept-Language. An explicit language choice outranks a browser default. Re-detecting on every visit undoes the user's decision and their goodwill.
  • Exclude assets in the matcher. Redirect everything and /favicon.ico becomes /en/favicon.ico, which is a 404 wearing a flag.
  • notFound() on unknown locales. [lang] matches any string; validate against your list before you try to load a dictionary for /klingon.
  • server-only in the dictionary module. Without it, one careless client import ships every translation to every visitor, silently.
  • English JSON is the schema. Record<Locale, () => Promise<Dictionary>> turns "forgot to translate the new key" into a build error instead of a support ticket in a language you don't read.
  • Logical CSS properties, always. margin-inline-start mirrors for free; margin-left is a bug you haven't met yet.
  • hreflang is reciprocal or it's nothing. Every locale lists every locale, plus x-default. One missing back-reference voids the set.
  • Prefix the locale once, in a wrapper. A LocalizedLink/useLocalizedRouter reading useParams().lang beats interpolating /${lang} at every call site — one typo, or one "tidy" edit back to a bare <Link>, silently drops the locale and bounces the visitor through the proxy into the wrong language.

Wrapping up

Count the moving parts: a five-line config, a [lang] segment with generateStaticParams, one proxy.ts doing cookie-then-header detection, JSON dictionaries typed off the English file, prop-passed slices for client components, one dir attribute backed by logical CSS, and hreflang alternates resolved against metadataBase. No i18n framework — just routing, types, and two packages that parse a header correctly.

The pattern underneath is the same one this blog keeps arriving at from different directions: one source of truth, everything else derived, the compiler guards the wiring. The forms post did it with a Zod schema. The SEO post did it with a schema graph. Today it was a JSON file that happens to speak English. At this point it's less a technique and more a personality trait — but it's the trait that makes "add German" a one-line diff plus a translation file, with the compiler personally escorting you to every place that needs attention. Sechs Pluralformen? Nein. German only has two. You got off easy.

Want the finished repo instead of assembling it from code blocks? Clone github.com/AbdelrahmanMostafa0/multi-language-site-in-nextjs and run it.

FAQ

Does the Next.js App Router have built-in i18n routing?

No. The i18n config in next.config only applies to the Pages Router. In the App Router you build it yourself: a [lang] dynamic segment for the URL, a proxy.ts that detects the visitor's locale and redirects, and generateStaticParams so every locale pre-renders. It's about 60 lines total.

How do I detect the user's preferred language in Next.js?

In proxy.ts (the Next.js 16 replacement for middleware.ts), check a locale cookie first — an explicit choice the user made — and fall back to the Accept-Language header, parsed with the negotiator package and matched against your supported locales with @formatjs/intl-localematcher. Then redirect to the prefixed path.

Can translated pages still be statically rendered?

Yes. Export generateStaticParams from the [lang] layout returning one entry per locale, and every locale version of every page pre-renders at build time. The redirect in proxy.ts only runs for un-prefixed URLs; prefixed pages are served as static files.

How do I support right-to-left languages like Arabic in Next.js?

Set dir="rtl" on the <html> element from your locale config in the [lang] layout, and use CSS logical properties (margin-inline-start, padding-inline-end, text-align: start) instead of left/right so the layout mirrors automatically. The browser does the flipping — if you let it.

No. next/link and useRouter don't auto-prefix the locale in the App Router — that was a Pages Router feature that didn't survive the move. Build a thin LocalizedLink (and a matching useLocalizedRouter) that reads the current locale from useParams().lang and prepends it to root-relative paths. Call sites then pass /projects and get /en/projects for free, while server components keep interpolating the lang prop they already receive.