Internationalization (i18n) giúp ứng dụng hỗ trợ nhiều ngôn ngữ. NextJS App Router có hỗ trợ i18n built-in thông qua routing. Bài này hướng dẫn implement i18n với next-intl — thư viện phổ biến nhất.
1. Cài đặt
npm install next-intl
2. Cấu trúc thư mục
src/
app/
[locale]/ ← Dynamic segment cho locale
layout.tsx
page.tsx
products/
page.tsx
messages/
vi.json ← Tiếng Việt
en.json ← Tiếng Anh
middleware.ts
i18n.ts
3. Cấu hình
// i18n.ts
import { notFound } from 'next/navigation';
import { getRequestConfig } from 'next-intl/server';
export const locales = ['vi', 'en'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'vi';
export default getRequestConfig(async ({ locale }) => {
if (!locales.includes(locale as Locale)) notFound();
return {
messages: (await import(`./messages/${locale}.json`)).default,
};
});
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n';
export default createMiddleware({
locales,
defaultLocale,
localePrefix: 'as-needed', // /vi/... cho vi, /en/... cho en, / cho default
});
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
};
4. Translation Files
// messages/vi.json
{
"common": {
"search": "Tìm kiếm",
"loading": "Đang tải...",
"error": "Đã có lỗi xảy ra",
"retry": "Thử lại",
"save": "Lưu",
"cancel": "Hủy",
"delete": "Xóa"
},
"nav": {
"home": "Trang chủ",
"products": "Sản phẩm",
"orders": "Đơn hàng",
"profile": "Hồ sơ"
},
"products": {
"title": "Danh sách sản phẩm",
"count": "{count} sản phẩm",
"addToCart": "Thêm vào giỏ",
"outOfStock": "Hết hàng",
"price": "{price}₫"
},
"auth": {
"login": "Đăng nhập",
"register": "Đăng ký",
"email": "Email",
"password": "Mật khẩu",
"forgotPassword": "Quên mật khẩu?",
"loginSuccess": "Đăng nhập thành công!",
"error": {
"invalidCredentials": "Email hoặc mật khẩu không đúng",
"emailRequired": "Email là bắt buộc"
}
}
}
// messages/en.json
{
"common": {
"search": "Search",
"loading": "Loading...",
"error": "Something went wrong",
"retry": "Retry",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete"
},
"nav": {
"home": "Home",
"products": "Products",
"orders": "Orders",
"profile": "Profile"
},
"products": {
"title": "Products",
"count": "{count} products",
"addToCart": "Add to Cart",
"outOfStock": "Out of Stock",
"price": "${price}"
},
"auth": {
"login": "Login",
"register": "Register",
"email": "Email",
"password": "Password",
"forgotPassword": "Forgot password?",
"loginSuccess": "Logged in successfully!",
"error": {
"invalidCredentials": "Invalid email or password",
"emailRequired": "Email is required"
}
}
}
5. Layout với locale
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales } from '@/i18n';
export function generateStaticParams() {
return locales.map(locale => ({ locale }));
}
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
if (!locales.includes(locale as any)) notFound();
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
6. Dùng trong Server Component
// app/[locale]/products/page.tsx
import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
// Server Component
export async function generateMetadata({ params: { locale } }) {
const t = await getTranslations({ locale, namespace: 'products' });
return { title: t('title') };
}
export default async function ProductsPage() {
const t = await getTranslations('products');
const products = await fetchProducts();
return (
<div>
<h1>{t('title')}</h1>
<p>{t('count', { count: products.length })}</p>
<div className="grid grid-cols-3 gap-4">
{products.map(p => (
<div key={p.id}>
<h3>{p.name}</h3>
<p>{t('price', { price: p.price.toLocaleString() })}</p>
</div>
))}
</div>
</div>
);
}
7. Dùng trong Client Component
'use client';
import { useTranslations, useLocale } from 'next-intl';
export function AddToCartButton({ productId, inStock }) {
const t = useTranslations('products');
const locale = useLocale();
return (
<button disabled={!inStock}>
{inStock ? t('addToCart') : t('outOfStock')}
</button>
);
}
8. Language Switcher
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { useLocale } from 'next-intl';
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const switchLocale = (newLocale: string) => {
// Thay đổi locale trong URL
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
router.push(newPath);
};
return (
<div className="flex gap-2">
<button
onClick={() => switchLocale('vi')}
className={locale === 'vi' ? 'font-bold' : 'text-gray-500'}
>
🇻🇳 Tiếng Việt
</button>
<button
onClick={() => switchLocale('en')}
className={locale === 'en' ? 'font-bold' : 'text-gray-500'}
>
🇬🇧 English
</button>
</div>
);
}
9. Format số, ngày tháng, tiền tệ
import { useFormatter, useNow } from 'next-intl';
export function ProductPrice({ price, date }) {
const format = useFormatter();
return (
<div>
{/* Format tiền tệ theo locale */}
<p>{format.number(price, { style: 'currency', currency: 'VND' })}</p>
{/* → 1.500.000 ₫ (vi) | ₫1,500,000 (en) */}
{/* Format ngày */}
<p>{format.dateTime(date, { dateStyle: 'medium' })}</p>
{/* → 25 thg 12, 2025 (vi) | Dec 25, 2025 (en) */}
{/* Relative time */}
<p>{format.relativeTime(date)}</p>
{/* → 3 ngày trước (vi) | 3 days ago (en) */}
</div>
);
}
10. Kết luận
[locale]segment: Routing tự động theo ngôn ngữ (/vi/products,/en/products)- Middleware: Tự redirect user đến đúng locale dựa trên browser language
- Server/Client Component:
getTranslations()(server),useTranslations()(client) - Format: Dùng
useFormatter()cho số, ngày, tiền — không hard-code format - Namespace: Chia messages thành namespace (auth, products, nav) để dễ quản lý
next-intl là lựa chọn tốt nhất cho App Router — TypeScript support tốt, không client bundle lớn.