Đa ngôn ngữ (i18n) với Next.js: /vi và /en bằng next-intl
App Router không có i18n sẵn; bạn dựng nó từ ba việc: đặt ngôn ngữ vào URL, đọc bản dịch, và giữ ngôn ngữ khi người đọc bấm link. Trang này làm ba việc đó trên một ứng dụng thật (tiếng Việt mặc định ở /vi, tiếng Anh ở /en), chạy 33 phép kiểm tra trên Next.js 16.4.3 và next-intl 4.14.9, rồi ghi lại cả những chỗ dễ sai mà tài liệu không nói rõ.
Mã trên trang này là một ứng dụng chạy được: 33/33 kiểm tra đạt ngày 02/11/2026. Phiên bản đã chạy thử: next-intl@4.14.9, next@16.4.3, react@19.3.1, typescript@7.0.2, Node.js v26.0.0.
Đặt ngôn ngữ vào URL
Tài liệu Next.js hướng dẫn cách làm chuẩn: mỗi ngôn ngữ một tiền tố URL, toàn bộ app/ nằm trong app/[lang]/, và ngôn ngữ lấy từ request (Accept-Language) khi URL chưa có. Pages Router từng có sẵn khoá i18n trong next.config.js; App Router thì không, nên đây là phần bạn tự dựng hoặc nhờ thư viện.
Đặt trong URL có lợi: mỗi ngôn ngữ một địa chỉ riêng để chia sẻ, để Google lập chỉ mục riêng, để cache riêng, và để người đọc thấy ngay mình đang xem bản nào. Cái giá: mọi đường dẫn nội bộ phải mang theo ngôn ngữ — nên dùng Link của thư viện thay cho thẻ a trần.
Bản dịch chỉ nên đọc trên máy chủ. Trong App Router, page và layout mặc định là Server Component, nên file JSON tiếng Việt không đi vào JavaScript gửi cho trình duyệt — trừ phần bạn chủ động đưa vào Client Component.
Tự viết hay dùng next-intl
Đường tối thiểu trong tài liệu Next.js: proxy.js đọc Accept-Language bằng negotiator, chọn ngôn ngữ bằng @formatjs/intl-localematcher, rồi chuyển hướng; bản dịch là hai object JSON, getDictionary(locale) nạp bằng import() động, hasLocale() chặn giá trị lạ bằng notFound(). Không thêm thư viện nào.
next-intl làm đúng phần đó và thêm: useTranslations cho cả Server và Client Component, Link/useRouter biết giữ ngôn ngữ, định dạng số, ngày, tiền theo locale qua Intl, thông điệp ICU (số nhiều, tham số), cookie NEXT_LOCALE để nhớ lựa chọn, và getTranslations({ locale }) cho metadata hay Route Handler. Giấy phép MIT.
Bản đã chạy ở đây: next-intl@4.14.9 khai báo hỗ trợ next từ 12 đến 16, nên Next.js 16 không phải vùng chưa thử. Trang này dùng next-intl vì đó là thứ người đọc tìm; nếu muốn ít phụ thuộc, ba việc bên dưới vẫn đúng khi bạn tự làm.
Cấu hình: một chỗ khai báo ngôn ngữ
i18n/routing.ts là nguồn duy nhất: danh sách ngôn ngữ, ngôn ngữ mặc định, kiểu tiền tố. localePrefix đặt là always thì /vi và /en đều có trên URL, kể cả ngôn ngữ mặc định; đổi sang as-needed thì tiếng Việt nằm ở / và chỉ tiếng Anh có tiền tố.
next.config.ts bọc cấu hình bằng plugin của next-intl; plugin tìm i18n/request.ts theo mặc định, nên không phải khai đường dẫn.
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",
});import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
export default withNextIntl({});proxy.ts: chọn ngôn ngữ trước khi trang chạy
Next.js 16 đổi tên file middleware.ts thành proxy.ts; tài liệu next-intl 4 cũng đã theo tên mới. Nội dung vẫn là một hàm chạy trước route: khi đường dẫn chưa có ngôn ngữ, nó trả 307 tới địa chỉ đã thêm ngôn ngữ.
Đo trên ứng dụng này: / với Accept-Language: vi-VN,vi;q=0.9 → 307 /vi; với en-US,en;q=0.9,vi;q=0.8 → 307 /en; không có header → 307 /vi (ngôn ngữ mặc định); /pricing → 307 /en/pricing, đường dẫn giữ nguyên.
Lần đầu vào một trang có ngôn ngữ, next-intl đặt cookie NEXT_LOCALE (ở đây: NEXT_LOCALE=en; Path=/). Lần sau cookie thắng Accept-Language: / kèm cookie đó → 307 /en dù trình duyệt khai tiếng Việt. Người đọc bấm đổi ngôn ngữ một lần là đủ.
matcher bỏ qua /api, _next, _vercel và mọi đường dẫn có dấu chấm (file tĩnh như favicon.ico) — nếu không, một request ảnh cũng bị chuyển hướng.
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|.*\\..*).*)",
};Bản dịch đọc ở đâu
i18n/request.ts chạy cho mỗi request: lấy ngôn ngữ từ đoạn [locale] bằng next/root-params (có sẵn từ Next.js 16.3), kiểm tra nằm trong danh sách, rồi nạp đúng file JSON. Ngôn ngữ lạ gọi notFound() — không có chuyện render với bản dịch rỗng.
app/[locale]/layout.tsx là layout gốc: đặt <html lang={locale}>, sinh generateStaticParams cho cả hai ngôn ngữ, và khai canonical + hreflang trong generateMetadata. Thuộc tính lang là thứ trình đọc màn hình và Google dùng để biết trang đang viết bằng tiếng gì.
Trong generateMetadata, luôn truyền locale tường minh vào getTranslations({ locale, namespace }): metadata chạy ngoài ngữ cảnh request của trang nên đừng dựa vào ngôn ngữ ngầm định.
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,
};
});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 Component và Client Component
Server Component gọi getTranslations và không cần truyền ngôn ngữ. Client Component ('use client') dùng useTranslations qua NextIntlClientProvider đặt trong layout.
Điểm dễ sai: provider chỉ nên nhận những namespace mà Client Component đọc. Ở đây nó nhận Client và Nav; namespace Pricing chỉ Server Component dùng nên không có trong HTML của /vi (đã kiểm). Truyền cả file bản dịch thì mọi bản dịch đi theo mọi trang.
Client Component vẫn được render sẵn thành HTML: tắt JavaScript, /vi vẫn hiện “0 lượt thích”, /en hiện “0 likes”. Chỉ phần bấm được mới cần JavaScript.
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>
);
}"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>
);
}Link và nút đổi ngôn ngữ
i18n/navigation.ts bọc Link, redirect, useRouter để chúng tự thêm ngôn ngữ hiện tại. Đo được: trên /vi, Link trỏ tới /pricing thành href="/vi/pricing"; nút đổi ngôn ngữ trên /vi trỏ tới href="/en", và trên /vi/pricing trỏ tới href="/en/pricing".
usePathname trả đường dẫn không có ngôn ngữ. Đo được: trên /vi/pricing, nút đổi ngôn ngữ (dựng từ usePathname rồi để Link thêm ngôn ngữ) trỏ tới /en/pricing — nghĩa là usePathname ở đó trả /pricing. Cộng tay /${locale} vào kết quả đó sẽ ra /vi/vi/pricing.
Thẻ a trần cho đường dẫn nội bộ là cách nhanh nhất để người đọc bị đẩy về ngôn ngữ mặc định — hoặc về một URL 404.
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);"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>
);
}Tiền, ngày, số nhiều
Định dạng do Intl của Node/trình duyệt quyết định theo locale; next-intl chỉ chuyển locale vào. Đo trên máy chủ: cùng số 1234567 → tiếng Việt 1.234.567 ₫, tiếng Anh ₫1,234,567; cùng ngày 2/11/2026 → 2 tháng 11, 2026 và November 2, 2026.
Số nhiều là chỗ tiếng Việt khác tiếng Anh về bản chất: Intl.PluralRules cho tiếng Việt chỉ một dạng (other), tiếng Anh có one và other. Nên thông điệp ICU {count, plural, ...} của tiếng Việt chỉ cần nhánh other; thêm nhánh one cũng không bao giờ chạy. Đã kiểm: 1 và 5 đều ra “1 tin nhắn”, “5 tin nhắn”; tiếng Anh ra “1 message”, “5 messages”.
Đừng tự nối chuỗi kiểu giá + " ₫": dấu phân cách nghìn, vị trí ký hiệu tiền và cách viết ngày khác nhau theo từng ngôn ngữ, và Intl đã biết hết.
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
Đo trên /vi: <html lang="vi">, <link rel="canonical" href="/vi">, và ba dòng alternate — vi=/vi, en=/en, x-default=/vi. Đây là cách nói với Google rằng hai địa chỉ là một trang hai ngôn ngữ, không phải nội dung trùng lặp; x-default trỏ về ngôn ngữ mặc định.
Một chi tiết khi kiểm tra bằng script: React in thuộc tính thành hrefLang (chữ L hoa). HTML không phân biệt hoa thường nên vẫn hợp lệ, nhưng biểu thức tìm kiếm phải bật cờ không phân biệt hoa thường — lần đầu chúng tôi grep hreflang và tưởng thiếu thẻ.
Đừng quên sitemap: khai cả hai URL, và để trình duyệt/Google thấy mỗi ngôn ngữ là một trang riêng. Ứng dụng mẫu ở đây sinh hai trang tĩnh cho mỗi đường dẫn, nên việc đưa chúng vào sitemap chỉ là liệt kê.
Route Handler theo ngôn ngữ
Route Handler nằm dưới [locale] nhận ngôn ngữ từ params, rồi gọi getTranslations({ locale }). next/root-params không dùng được trong Route Handler, Server Action hay Client Component — tài liệu Next.js nói rõ — nên ở đây phải truyền tay.
Đo: GET /vi/api/hello → { locale: "vi", greeting: "Xin chào từ máy chủ" }; /en/api/hello trả bản tiếng Anh.
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") });
}Lỗi hay gặp
Client Component nằm ngoài provider: next build dừng ở bước prerender, lỗi chỉ đúng dòng useLocale() mà không nói thiếu provider. Chúng tôi gặp đúng lỗi này khi để nút đổi ngôn ngữ trong <nav> ngoài NextIntlClientProvider. Cách sửa: bọc luôn cả phần tử đó, hoặc đặt provider cao hơn.
Ngôn ngữ không tồn tại: /de không phải ngôn ngữ đã khai, nên proxy coi de là một đoạn đường dẫn → 307 /vi/de, rồi 404. Vì thế trang 404 phải có cả hai ngôn ngữ: file app/not-found.tsx không nhận được [locale], nên ở đây nó viết “Không tìm thấy trang / Page not found”.
Tên file proxy: Next.js 16 gọi là proxy.ts; các bài viết và dự án cũ dùng middleware.ts. next-intl 4 cũng đã chuyển sang proxy.ts — chép nguyên file cũ vào dự án Next 16 là chỗ dễ mất thời gian.
Bắt đầu từ đâu
Thứ tự gọn nhất: khai locales và defaultLocale; thêm proxy.ts; chuyển app/ vào app/[locale]/; thêm generateStaticParams; thay thẻ a nội bộ bằng Link của thư viện; khai lang và alternates; rồi thêm ngôn ngữ thứ hai vào file bản dịch.
Kiểm tra sau mỗi bước bằng ba câu: / có chuyển tới đúng ngôn ngữ không, HTML có lang đúng không, và bấm một link nội bộ có bị đổi ngôn ngữ không. Ba câu đó bắt được gần hết lỗi cấu hình.
Đã kiểm tra
- 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
Chạy lại kiểm tra: bun scripts/guides/verify-i18n.ts
Liên quan
- Next.js là gì? Frontend hay backend, dùng khi nào
- Lỗ hổng bảo mật Next.js
- Kiểm tra phiên bản Next.js của trang bạn
Nguồn
- Next.js docs — Internationalization
- Next.js docs — proxy.js (Middleware renamed to Proxy in v16.0.0)
- Next.js docs — next/root-params
- next-intl docs — Setup locale-based routing
- next-intl docs — NextIntlClientProvider and messages
- amannn/next-intl (MIT licence)
- npm registry — next-intl dist-tags
Tóm tắt và diễn giải bằng lời của chúng tôi; mã viết lại từ ví dụ trong tài liệu Next.js. Khi có khác biệt, tài liệu Next.js là chuẩn.