Tiếng Việt

Home

Multilingual Next.js: /vi and /en with next-intl

The App Router ships no i18n; you build it from three things: a locale in the URL, a way to read the messages, and links that keep the locale. This page does all three in a real app (Vietnamese by default at /vi, English at /en), runs 33 checks on Next.js 16.4.3 and next-intl 4.14.9, and records the mistakes the docs do not spell out.

The code on this page is an app that runs: 33/33 checks passed on 2026-11-02. Versions run: next-intl@4.14.9, next@16.4.3, react@19.3.1, typescript@7.0.2, Node.js v26.0.0.

Put the locale in the URL

The Next.js docs give the standard shape: one URL prefix per language, everything under app/ moved into app/[lang]/, and the language taken from the request (Accept-Language) when the URL has none. The Pages Router used to offer an i18n key in next.config.js; the App Router does not, so this part is yours to build or to take from a library.

A URL prefix buys four things: an address per language to share, a page per language for search engines, a cache entry per language, and a reader who can see which version they are on. It costs one thing: every internal link must carry the locale — which is why you use the library's Link instead of a bare a tag.

Read messages on the server only. Pages and layouts are Server Components by default, so the Vietnamese JSON never reaches browser JavaScript — except the part you deliberately hand to a Client Component.

Write it yourself, or use next-intl

The minimal path in the Next.js docs: proxy.js reads Accept-Language with negotiator, picks a language with @formatjs/intl-localematcher, and redirects; the messages are two JSON objects, getDictionary(locale) loads one with a dynamic import(), and hasLocale() turns an unknown value into notFound(). No library at all.

next-intl does that part and adds: useTranslations for both Server and Client Components, Link/useRouter that keep the locale, number, date and currency formatting per locale through Intl, ICU messages (plurals, arguments), a NEXT_LOCALE cookie that remembers the choice, and getTranslations({ locale }) for metadata or a Route Handler. MIT licence.

The version run here: next-intl@4.14.9 declares support for next 12 through 16, so Next.js 16 is not untested ground. This page uses next-intl because that is what readers search for; if you prefer fewer dependencies, the three jobs below still hold when you do them yourself.

Configuration: one place names the languages

i18n/routing.ts is the single source: the locales, the default locale, the prefix style. With localePrefix set to always, both /vi and /en appear in the URL, the default language included; switch it to as-needed and Vietnamese sits at / with only English prefixed.

next.config.ts wraps the config in the next-intl plugin, which finds i18n/request.ts by default, so no path is needed.

i18n/routing.ts
import { defineRouting } from "next-intl/routing";

// One place for the languages this app serves. `localePrefix: "always"` keeps both languages on
// their own URL (/vi, /en), so each one can be indexed and linked on its own.
export const routing = defineRouting({
  locales: ["vi", "en"],
  defaultLocale: "vi",
  localePrefix: "always",
});
next.config.ts
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin();

export default withNextIntl({});

proxy.ts: pick the language before the page runs

Next.js 16 renamed middleware.ts to proxy.ts, and the next-intl 4 docs follow the new name. The content is still a function that runs before the route: when the path has no locale, it answers 307 with a URL that has one.

Measured on this app: / with Accept-Language: vi-VN,vi;q=0.9 → 307 /vi; with en-US,en;q=0.9,vi;q=0.8 → 307 /en; no header → 307 /vi (the default locale); /pricing → 307 /en/pricing, the path kept.

The first visit to a page that has a locale sets a NEXT_LOCALE cookie (here: NEXT_LOCALE=en; Path=/). Next time the cookie beats Accept-Language: / with that cookie → 307 /en even though the browser asks for Vietnamese. One click on the language switch is enough.

The matcher skips /api, _next, _vercel and every path with a dot (static files such as favicon.ico) — without it, an image request would be redirected too.

proxy.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";

// Next.js 16 calls this file proxy.ts (middleware.ts up to Next.js 15). It runs before every request
// that has no language in the path and answers 307 with one: the language from the NEXT_LOCALE
// cookie, else from Accept-Language, else routing.defaultLocale.
export default createMiddleware(routing);

export const config = {
  // Skip API routes, Next.js internals and anything with a dot (files such as favicon.ico).
  matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
};

Where the messages are read

i18n/request.ts runs per request: it reads the locale from the [locale] segment through next/root-params (available since Next.js 16.3), checks it against the list, then loads the matching JSON file. An unknown locale calls notFound() — there is no render with an empty dictionary.

app/[locale]/layout.tsx is the root layout: it sets <html lang={locale}>, declares generateStaticParams for both languages, and writes canonical and hreflang in generateMetadata. The lang attribute is what screen readers and Google use to know which language the page is in.

In generateMetadata, always pass locale explicitly to getTranslations({ locale, namespace }): metadata runs outside the page's request context, so do not rely on an implicit locale.

i18n/request.ts
import * as rootParams from "next/root-params";
import { notFound } from "next/navigation";
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";

// The locale comes from the [locale] segment through next/root-params (Next.js 16.3+), so pages and
// Server Components can read translations without passing `locale` through every layer.
export default getRequestConfig(async ({ locale }) => {
  const value = locale ?? (await rootParams.locale());
  if (!hasLocale(routing.locales, value)) notFound();
  return {
    locale: value,
    messages: (await import(`../messages/${value}.json`)).default,
  };
});
app/[locale]/layout.tsx
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { hasLocale, NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations } from "next-intl/server";
import { routing } from "@/i18n/routing";
import LangSwitch from "./ui/lang-switch";

// Both languages are rendered at build time: one static page per locale.
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "Meta" });
  return {
    title: t("title"),
    description: t("description"),
    // hreflang: search engines learn that /vi and /en are the same page in two languages.
    alternates: { canonical: `/${locale}`, languages: { vi: "/vi", en: "/en", "x-default": "/vi" } },
  };
}

export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) notFound();

  // A Client Component reads messages through this provider, from the RSC payload — so it gets only
  // the namespaces it needs, not the whole file. Everything a client component can reach must sit
  // inside the provider, including <LangSwitch> in the nav.
  const m = await getMessages();
  const t = await getTranslations("Nav");
  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider locale={locale} messages={{ Client: m.Client, Nav: m.Nav }}>
          <nav>
            <a href={`/${locale}`}>{t("home")}</a> <a href={`/${locale}/pricing`}>{t("pricing")}</a> <LangSwitch />
          </nav>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Server Components and Client Components

A Server Component calls getTranslations and never passes a locale. A Client Component ('use client') uses useTranslations through the NextIntlClientProvider placed in the layout.

The easy mistake: the provider should receive only the namespaces a Client Component reads. Here it gets Client and Nav; the Pricing namespace is server-only, so it is not in the HTML of /vi (checked). Pass the whole file and every translation rides along on every page.

A Client Component is still pre-rendered to HTML: with JavaScript off, /vi shows “0 lượt thích” and /en shows “0 likes”. Only the click needs JavaScript.

app/[locale]/page.tsx
import { getTranslations } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import LikeCounter from "./ui/like-counter";

export default async function HomePage() {
  const t = await getTranslations("Home");
  return (
    <main>
      <h1>{t("title")}</h1>
      <p>{t("lede")}</p>
      <p>
        <Link href="/pricing">{t("toPricing")}</Link>
      </p>
      <LikeCounter />
    </main>
  );
}
app/[locale]/ui/like-counter.tsx
"use client";

import { useState } from "react";
import { useTranslations } from "next-intl";

// A Client Component with state: the browser receives only the "Client" namespace through the
// provider in layout.tsx, and the plural is chosen by the locale's plural rules.
export default function LikeCounter() {
  const [n, setN] = useState(0);
  const t = useTranslations("Client");
  return (
    <p>
      <button onClick={() => setN(n + 1)}>{t("likes", { count: n })}</button> <small>{t("hint")}</small>
    </p>
  );
}

Links and the language switch

i18n/navigation.ts wraps Link, redirect and useRouter so they add the current locale. Measured: on /vi, a Link to /pricing renders href="/vi/pricing"; the language switch points at href="/en" on the home page and href="/en/pricing" on the pricing page.

usePathname returns the path without the locale. Measured: on /vi/pricing the language switch (built from usePathname, with Link adding the locale) points at /en/pricing — so usePathname returned /pricing. Prepending /${locale} by hand gives /vi/vi/pricing.

A bare a tag for an internal path is the fastest way to drop a reader back to the default language — or onto a 404.

i18n/navigation.ts
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";

// Link/redirect/useRouter that keep the current language: <Link href="/pricing"> becomes
// /vi/pricing or /en/pricing, never a jump back to the default language.
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
app/[locale]/ui/lang-switch.tsx
"use client";

import { useLocale, useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";

// Runs in the browser: it knows the current locale and links to the same page in the other one.
export default function LangSwitch() {
  const locale = useLocale();
  const pathname = usePathname();
  const t = useTranslations("Nav");
  const other = locale === "vi" ? "en" : "vi";
  return (
    <Link href={pathname} locale={other} data-testid="lang-switch">
      {t("other")}: {other}
    </Link>
  );
}

Money, dates, plurals

Intl in Node and in the browser decides the formatting from the locale; next-intl only passes the locale in. Measured on the server: the same 1234567 → Vietnamese 1.234.567 ₫, English ₫1,234,567; the same 2 Nov 2026 → 2 tháng 11, 2026 and November 2, 2026.

Plurals differ in kind, not only in words: Intl.PluralRules gives Vietnamese a single form (other), while English has one and other. So a Vietnamese ICU message {count, plural, ...} needs only the other branch; a one branch would never run. Checked: 1 and 5 both give “1 tin nhắn”, “5 tin nhắn”; English gives “1 message”, “5 messages”.

Do not build the string by hand (price + " ₫"): the thousands separator, the position of the currency symbol and the date wording all differ per language, and Intl already knows them.

app/[locale]/pricing/page.tsx
import { getFormatter, getTranslations } from "next-intl/server";

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "Pricing" });
  return { title: t("title"), alternates: { languages: { vi: "/vi/pricing", en: "/en/pricing" } } };
}

export default async function PricingPage() {
  const t = await getTranslations("Pricing");
  const f = await getFormatter();
  // Intl follows the locale: Vietnamese groups thousands with dots, English with commas.
  const price = f.number(1234567, { style: "currency", currency: "VND", maximumFractionDigits: 0 });
  const date = f.dateTime(new Date("2026-11-02T00:00:00Z"), { dateStyle: "long", timeZone: "UTC" });
  return (
    <main>
      <h1>{t("title")}</h1>
      <p data-testid="price">{t("price", { price })}</p>
      <p data-testid="plural-1">{t("messages", { count: 1 })}</p>
      <p data-testid="plural-5">{t("messages", { count: 5 })}</p>
      <p data-testid="date">{t("since", { date })}</p>
    </main>
  );
}

SEO: lang, canonical, hreflang

Measured on /vi: <html lang="vi">, <link rel="canonical" href="/vi">, and three alternate lines — vi=/vi, en=/en, x-default=/vi. That tells Google the two addresses are one page in two languages, not duplicate content; x-default points at the default language.

One detail when you check with a script: React writes the attribute as hrefLang (capital L). HTML ignores attribute case, so it is valid, but your search pattern must be case-insensitive — on the first run we grepped hreflang and concluded the tag was missing.

Do not forget the sitemap: list both URLs so each language is a page of its own. The sample app renders both locales to static pages, so adding them to a sitemap is just listing them.

A Route Handler per language

A Route Handler under [locale] takes the locale from params, then calls getTranslations({ locale }). next/root-params does not work in Route Handlers, Server Actions or Client Components — the Next.js docs say so — so here it is passed by hand.

Measured: GET /vi/api/hello → { locale: "vi", greeting: "Xin chào từ máy chủ" }; /en/api/hello answers in English.

app/[locale]/api/hello/route.ts
import { getTranslations } from "next-intl/server";

// A Route Handler under [locale] answers in the language of its URL: /vi/api/hello and /en/api/hello.
export async function GET(_request: Request, { params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "Api" });
  return Response.json({ locale, greeting: t("hello") });
}

Common mistakes

A Client Component outside the provider: next build stops at prerender with an error that names the useLocale() line and says nothing about a missing provider. That is exactly what hit us when the language switch sat in a <nav> outside NextIntlClientProvider. The fix: wrap that element too, or move the provider higher.

A language nobody serves: /de is not a declared locale, so the proxy treats de as a path segment → 307 /vi/de, then 404. That is why the 404 must cover both languages: app/not-found.tsx gets no [locale], so here it says “Không tìm thấy trang / Page not found”.

The proxy file name: Next.js 16 calls it proxy.ts; older posts and projects use middleware.ts. next-intl 4 moved to proxy.ts too, so copying an old file verbatim into a Next 16 project is a good way to lose an afternoon.

Where to start

The shortest order: declare locales and defaultLocale; add proxy.ts; move app/ into app/[locale]/; add generateStaticParams; replace internal a tags with the library's Link; declare lang and alternates; then add the second language to the message files.

After each step, check three things: does / land on the right language, does the HTML carry the right lang, and does an internal link keep the language. Those three catch almost every configuration mistake.

Checked

  • detection: Accept-Language: vi sends / to the Vietnamese page: 307 /vi
  • detection: Accept-Language: en sends / to the English page: 307 /en
  • detection: no Accept-Language falls back to the default locale: 307 /vi
  • detection: the path after the language is kept: 307 /en/pricing
  • cookie: the language page sets NEXT_LOCALE: NEXT_LOCALE=en; Path=/
  • cookie: NEXT_LOCALE wins over Accept-Language: 307 /en
  • /vi: 200, the page declares its language: 200 lang=vi
  • /vi: Server Component text is in the HTML: Một trang, hai ngôn ngữ
  • /vi: Client Component is translated with JavaScript off: 0 lượt thích
  • /vi: links stay in this language: href="/vi/pricing"
  • /en: 200, the page declares its language: 200 lang=en
  • /en: Server Component text is in the HTML: One page, two languages
  • /en: Client Component is translated with JavaScript off: 0 likes
  • /en: links stay in this language: href="/en/pricing"
  • language switch: /vi links to the same page in English: href="/en"
  • /vi: hreflang names both languages and a default: vi=/vi en=/en x-default=/vi
  • /vi: canonical is this language's URL: /vi
  • language switch: a sub-page keeps its path: href="/en/pricing"
  • number: Vietnamese groups thousands with dots and puts ₫ after: 1.234.567 ₫
  • number: English groups with commas and puts ₫ first: ₫1,234,567
  • date: Vietnamese long date: 2 tháng 11, 2026
  • date: English long date: November 2, 2026
  • plural, vi, 1 and 5: one form: 1 tin nhắn 5 tin nhắn
  • plural, en, 1 and 5: two forms: 1 message 5 messages
  • route handler: /vi/api/hello answers in Vietnamese: {"locale":"vi","greeting":"Xin chào từ máy chủ"}
  • route handler: /en/api/hello answers in English: {"locale":"en","greeting":"Hello from the server"}
  • unknown language: /de has no locale of its own: 307 /vi/de
  • unknown language: /vi/de is a 404: 404
  • unknown language: the 404 page covers both languages: Không tìm thấy trang
  • payload: /vi carries the Client namespace: 0 lượt thích
  • payload: /vi does not carry the Pricing namespace: absent
  • build: both languages of both pages are prerendered: /vi /en /vi/pricing /en/pricing
  • size: the two home pages, before compression: 7457 / 7540 bytes

Re-run the checks: bun scripts/guides/verify-i18n.ts

Related

Sources

Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.