Next.js Localization: i18n Routing, Translations, a Language Switcher, and the RTL U-Turn
Published · Last updated · by Abdelrahman Mostafa
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 Next.js localization by hand: i18n routing with locale prefixes, automatic language detection, type-safe translations, a language switcher, RTL support, and the hreflang 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
/aboutgets redirected to their language, via cookie first andAccept-Languagesecond - Typed dictionaries — translations where a missing key is a compile error, not a blank spot in production
- A language switcher — that keeps the visitor on the same page and remembers their choice
- 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:
// 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: i18n routing with a [lang] segment — the URL owns the locale
Move your routes under a dynamic segment:
app/
[lang]/
layout.tsx
page.tsx
about/
page.tsxThe layout validates the locale, sets the <html> attributes, and — critically — tells Next.js to pre-render every locale:
// 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:
paramsis a Promise in current Next.js —awaitit. 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):
npm i negotiator @formatjs/intl-localematcher
npm i -D @types/negotiator// 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: translations — typed dictionaries where the type safety pays rent
Translations live in plain JSON, one file per locale:
// 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:
// 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. Deletenav.blogfromar.jsonand 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:
// 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.
// 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} />;
}// 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.
Getting the current locale in Next.js
With a [lang] segment there's no hidden "current locale" state to sync. The URL is the locale, and every place that needs it can read it:
// server components, pages, layouts, generateMetadata
const { lang } = await params;
// client components
const { lang } = useParams<{ lang: Locale }>();
// route handlers and proxy.ts
const lang = request.nextUrl.pathname.split("/")[1];Those casts to Locale scattered through the earlier steps deserve a proper type guard. Validate once, and get a narrowed type out of it:
// lib/i18n/config.ts
export function isLocale(value: string): value is Locale {
return (locales as readonly string[]).includes(value);
}
// app/[lang]/layout.tsx
const { lang } = await params;
if (!isLocale(lang)) notFound();
// lang is Locale from here on, no `as` neededWhat about a server component five levels deep that never got params? Pass lang down as a prop. It's tempting to stash the locale in a request header from proxy.ts and read it with headers(), but headers() opts the route into dynamic rendering. You'd give up the static pages generateStaticParams just bought you to avoid typing a prop. That's a bad trade.
Building a language switcher
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):
// components/locale-switcher.tsx
"use client";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { locales, type Locale } from "@/lib/i18n/config";
// each language named in itself — an Arabic speaker looks for "العربية", not "Arabic"
const labels: Record<Locale, string> = {
en: "English",
ar: "العربية",
fr: "Français",
};
export function LocaleSwitcher() {
const pathname = usePathname(); // e.g. /en/about
const { lang } = useParams<{ lang: Locale }>();
function switchTo(locale: Locale) {
const rest = pathname.split("/").slice(2).join("/");
return `/${locale}${rest ? `/${rest}` : ""}`;
}
return (
<nav aria-label="Language">
{locales.map((locale) => (
<Link
key={locale}
href={switchTo(locale)}
hrefLang={locale}
lang={locale}
aria-current={locale === lang ? "true" : undefined}
onClick={() => {
document.cookie = `locale=${locale}; path=/; max-age=31536000; samesite=lax`;
}}
>
{labels[locale]}
</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.
Three small attributes do real work:
- Real links, not a
<select>. A dropdown that callsrouter.pushinonChangeworks for people and is invisible to crawlers.<a href>links between language versions are crawl paths, and they reinforce the hreflang you'll declare below. hrefLangandlangtell browsers, screen readers, and crawlers which language each link leads to.lang="ar"also makes a screen reader pronounce "العربية" as Arabic instead of spelling it out.aria-currentmarks the active language, so you can style it and assistive tech can announce it.
One limitation: usePathname() drops the query string, so /en/search?q=villa switches to a bare /ar/search. If that matters, append useSearchParams().toString(). In a statically rendered page that hook needs a <Suspense> boundary around the switcher.
The /${lang} tax: links that prefix themselves
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:
// 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:
// 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:
<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 sightTwo 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 and Arabic in Next.js: 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:
/* 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.
{
/* 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.
Logical properties cover layout. A few RTL details they don't cover, learned shipping Arabic-first products (EgyStay and Lesoll, both used mostly in Arabic):
- Directional icons need a flip, not every icon. Arrows, chevrons, and "back" buttons point the wrong way in RTL. Search icons, checkmarks, and logos don't. Tailwind's
rtl:variant handles the ones that do:<ChevronRight className="rtl:rotate-180" />. - Mixed-direction text needs
dir="auto". User content mixes scripts: an Arabic listing title with an English brand name, or a phone number inside an Arabic sentence. Putdir="auto"on inputs and user-generated text so each one picks its direction from its own first strong character. - Load an Arabic font on purpose. Most Latin display fonts have no Arabic glyphs, so the browser falls back to a system font that rarely matches. Pick a family with an Arabic subset (IBM Plex Sans Arabic, Noto Sans Arabic, Cairo), load it with
next/font, and switch the font variable in the[lang]layout. - Numbers are a product decision.
Intlwill happily format٢٠٢٦with Arabic-Indic digits forar. Many Egyptian products show Western digits for prices and phone numbers anyway. Usear-EGwith{ numberingSystem: "latn" }when that's what your users expect, and decide it once in a formatter, not per component.
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:
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 in Next.js: 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. Write one helper that knows every locale, and give it the page's locale-free path:
// lib/i18n/alternates.ts
import type { Metadata } from "next";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
export function localeAlternates(
lang: Locale,
path: string, // "/about", or "" for the home page
): Metadata["alternates"] {
return {
canonical: `/${lang}${path}`,
languages: {
...Object.fromEntries(locales.map((l) => [l, `/${l}${path}`])),
"x-default": `/${defaultLocale}${path}`,
},
};
}Then every page declares its own:
// app/[lang]/about/page.tsx
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: Locale }>;
}): Promise<Metadata> {
const { lang } = await params;
return { alternates: localeAlternates(lang, "/about") };
}Per page, not in the [lang] layout. It's tempting to write this once in the layout, but metadata from a layout is inherited by every page beneath it. A layout-level canonical: `/${lang}` tells Google that /en/about, /en/blog, and every other English page are duplicates of /en. That's the canonical trap from the technical SEO post in a new costume, and an earlier version of this post had exactly that snippet. If you copied it, move it. next-seo-doctor crawls your built site and flags canonicals that don't point at the page they're on.
The relative paths resolve against the metadataBase you set once in the root layout. Two rules crawlers actually enforce:
x-defaultnames 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:
// 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}`]),
),
},
})),
);What about next-intl?
Everything above is what an i18n library does for you, which makes this the right moment to say: for a big app, use one. EgyStay runs on next-intl with around 2,300 translation keys per locale, and I wouldn't hand-roll ICU plural syntax at that scale. If you're on next-intl, or deciding whether to be, here's how each piece of this guide maps onto it:
| This guide | next-intl |
|---|---|
lib/i18n/config.ts | defineRouting({ locales, defaultLocale }) from next-intl/routing |
detection in proxy.ts | export default createMiddleware(routing) from next-intl/middleware, in proxy.ts |
await params / useParams().lang | await getLocale() from next-intl/server, useLocale() from next-intl |
getDictionary(lang) | await getTranslations("home") / useTranslations("home") |
LocalizedLink, useLocalizedRouter | Link, useRouter, usePathname from createNavigation(routing) |
LocaleSwitcher | router.replace(pathname, { locale: "ar" }) with next-intl's usePathname |
localeAlternates() | middleware sends hreflang as a Link header (the alternateLinks option) |
generateStaticParams | same, plus setRequestLocale(locale) in each layout and page |
Two next-intl points that people search for:
- "next-intl get locale": on the server it's
getLocale(), but in a page or layout that already hasparams,await paramsis cheaper and keeps static rendering simple. UsegetLocale()in deep server components that never gotparams. - "next-intl hreflang": the middleware's
Linkheader covers crawlers that read it. If you also want<link rel="alternate">tags in the HTML, which is what most SEO tools check, keep alocaleAlternates-style helper ingenerateMetadataanyway. It's ten lines and you already have it.
The hand-built version earns its place on smaller sites: about 60 lines, zero dependencies, and nothing between you and the request. Either way, you now know what the library is doing on your behalf.
Gotchas worth remembering
- It's
proxy.tsin Next.js 16, notmiddleware.ts. Same request-layer job, renamed file, exported function isproxy. Tutorials showingmiddleware.tspredate 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.icobecomes/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-onlyin 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-startmirrors for free;margin-leftis 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. - Canonical and hreflang go in each page's
generateMetadata, never the[lang]layout. Layout metadata is inherited, and a layout canonical turns every page into a duplicate of the home page. - The switcher is links, not a dropdown. Crawlers follow
<a href hrefLang>. They don't fireonChange. - Prefix the locale once, in a wrapper. A
LocalizedLink/useLocalizedRouterreadinguseParams().langbeats 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, a language switcher made of real links, one dir attribute backed by logical CSS, and per-page 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.
How do I get the current locale in Next.js?
With a [lang] segment, the URL is the locale. In server components, pages, layouts, and generateMetadata, read it from params: const { lang } = await params. In client components, use useParams().lang from next/navigation. With next-intl, call getLocale() from next-intl/server on the server and useLocale() from next-intl on the client.
How do I build a language switcher in Next.js?
Render a real link per locale that swaps the first path segment of usePathname(), so /en/about becomes /ar/about and the visitor stays on the same page. Add hrefLang to each link and save the choice in a cookie that proxy.ts reads before Accept-Language. With next-intl, use the useRouter and usePathname from createNavigation and call router.replace(pathname, { locale }).
How do I add hreflang tags in Next.js?
Return alternates.languages from each page's generateMetadata, listing every locale's URL for that page plus an x-default. Do it per page, not in the [lang] layout. A layout's canonical and alternates are inherited by every page below it, which would point every page at the home page. next-intl's middleware also sends hreflang alternates as a Link response header by default.
Should I use next-intl instead of building i18n by hand?
For a large app, often yes. next-intl handles routing, locale detection, ICU plurals, formatting, and typed messages, and it does the same jobs this guide builds by hand. The hand-built version is about 60 lines with zero dependencies. That suits small sites, and it makes clear what a library does for you.
Do I have to write /${lang} in every link in Next.js?
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.
Still here? Read more
no ads, no popups, just the void offering you more content.
Technical SEO in Next.js: Crawlability, Sitemaps, Canonicals, and JSON-LD
Make a Next.js App Router site crawlable: server-rendered HTML, sitemap.ts, robots.ts, canonical URLs, the Metadata API, and XSS-safe JSON-LD.
React Hook Form + Zod: zodResolver, Conditional Validation, and Multi-Step Forms
Set up React Hook Form with Zod and zodResolver in TypeScript — install, z.infer types, conditional and async validation, and multi-step forms.