Xây site Next.js song ngữ với next-intl
i18n EN/VI thực tế cho site marketing và portfolio — routing, messages, nội dung MDX, và cái gì nên dịch / cái gì nên giữ nguyên.
Nguyễn Đình Giang
· · 4 phút đọc
Lập trình viên frontend ở Hà Nội. Mình viết về Next.js, WordPress và hiệu năng của những thứ mình thực sự ship.
Ship một ngôn ngữ thì dễ. Ship tiếng Anh và tiếng Việt mà không nhân đôi cả app mới là chỗ hầu hết site marketing bị rối.
Mình dùng next-intl trên portfolio và cùng một mental model khi dự án khách cần hai locale: cấu trúc UI chung, copy theo ngôn ngữ, metadata nhận locale.
Routing không đối đầu SEO
Mình thích localePrefix: "as-needed" — English ở /, tiếng Việt ở /vi/.... Locale mặc định sạch khi share, crawler vẫn có URL ngôn ngữ rõ và hreflang.
// i18n/routing.ts (rút gọn)
export const routing = {
locales: ["en", "vi"],
defaultLocale: "en",
localePrefix: "as-needed",
}Mỗi trang public nên set title, description và Open Graph theo locale. Người Việt share link phải thấy preview tiếng Việt.
Tách copy “chrome” khỏi “content”
Mình giữ hai lớp:
- Chuỗi UI — nav, nút, empty state →
messages/en.json+messages/vi.json - Nội dung dài — blog MDX, mô tả dự án, case study → file riêng hoặc override theo locale
const t = useTranslations("Nav")
return <Link href="/projects">{t("projects")}</Link>Đừng nhét cả bài blog vào JSON. JSON dành cho chuỗi ngắn, tái dùng. MDX dành cho bài viết.
Mình dịch gì trong thực tế
| Nên dịch | Thường giữ nguyên |
|---|---|
| Headline, CTA, label form | Tên brand, tên sản phẩm |
| Meta title/description | Code sample |
| Narrative case study | Nhãn tech stack (Next.js, GSAP) |
| Cách viết ngày / “Hiện tại” | Alt logo nếu brand khóa cứng |
Tiếng Việt không phải tiếng Anh đổi từ — độ dài câu đổi layout. Luôn check lại wrap trên nút và dòng hero sau khi dịch.
Gotcha mình từng dính
- English hard-code trong component bỏ qua file message — search literal trước launch
- Ảnh có chữ burn-in cần bản theo locale hoặc redesign
- Format ngày và số điện thoại khác nhau; đừng mặc định
en-USmọi nơi - Client component đọc message vẫn cần đúng cây provider
Canonical và hreflang phải đồng ý với nhau
hreflang không cứu được một trang tự nói rằng bản tiếng Anh mới là bản chính. Trên portfolio này, mỗi URL /vi/... phải có <link rel="canonical"> trỏ về chính nó, và hreflang vẫn khai báo cặp en / vi / x-default. Google ưu tiên canonical khi hai tín hiệu mâu thuẫn, nên một trang tiếng Việt canonical về /projects sẽ bị coi là bản trùng của trang tiếng Anh.
Helper metadata phải nhận locale đang phục vụ, không hard-code locale mặc định:
const locale = await getLocale()
const localizedPath = locale === "en" ? path : `/${locale}${path}`
return {
alternates: {
canonical: localizedPath,
languages: {
en: absolute(path, "en"),
vi: absolute(path, "vi"),
"x-default": absolute(path, "en"),
},
},
openGraph: {
url: absolute(path, locale),
locale: locale === "vi" ? "vi_VN" : "en_US",
},
}Re-export generateMetadata từ file tiếng Anh sang route /vi chỉ an toàn khi hàm đó tự gọi getLocale(). Nếu không, trang tiếng Việt kế thừa canonical tiếng Anh.
Dịch title, không chỉ body
lang="vi" với body đã dịch mà <title> và meta description vẫn tiếng Anh là một trang lai. Crawler và preview Zalo/Facebook đọc title trước. Chuỗi nav, form, và meta nằm trong messages/vi.json. Bài dài và case study nằm trong file MDX hoặc object copy riêng, không nhét vào JSON.
Checklist trước khi coi một route là xong:
- Title và description tiếng Việt trên
/vi/... - Canonical tự tham chiếu
og:urllà URL/vi,og:localelàvi_VN- Một đoạn body không còn câu tiếng Anh hard-code (search literal trong component)
- Sitemap có cả URL
/vilẫn URL tiếng Anh, mỗi cái một<loc>, sau khi canonical đã đúng
Ảnh có chữ burn-in vẫn cần bản riêng hoặc bỏ chữ. Ngày và số điện thoại format theo locale, không khóa en-US.
Cách mình đang chạy trên site này là English ở / và tiếng Việt ở /vi. Case study đi cùng mô hình đó, ví dụ nền tảng tour đa ngôn ngữ. Tài liệu routing: next-intl routing.
Lượt review mình thực sự chạy
Mình không coi “có file JSON” là xong. Sau khi chuỗi vào, mình mở / và /vi trên cùng một template và kiểm tra bốn thứ trong HTML đã render, không phải trong editor:
<html lang>khớp body.lang="vi"mà title vẫn tiếng Anh là fail, kể cả khi đoạn văn đã dịch.- Canonical khớp thanh địa chỉ. Ở
/vi/bloghref đó phải chứa/vi/blog. Ở/blogthì không. hreflangcó đi có lại: trang tiếng Anh trỏ/vi/..., trang tiếng Việt trỏ ngược lại.- Nút và hero vẫn vừa. Tiếng Việt dài hơn, nên label một dòng ở tiếng Anh có thể xuống dòng trên nav hoặc menu mobile.
Key thiếu là lỗi còn lại. Mình giữ tập key giống nhau giữa en.json và vi.json, diff path trước khi ship, để CTA mới không lọt tiếng Anh trên site tiếng Việt. Tên brand, code, và nhãn stack giữ nguyên. Mọi thứ người đọc trong chrome đều có một dòng tiếng Việt.
Kết
i18n là quyết định sản phẩm, không chỉ cài thư viện. Quyết sớm phần nào phải song ngữ, giữ message gọn, và coi QA tiếng Việt là một phần design review — không phải việc dồn ngày cuối vào Google Translate.
Cần tính năng kiểu này cho site của bạn? Xem trang thuê →
Nhận bài mới qua email
Mỗi khi có bài mới, mình gửi một email. Không spam, huỷ lúc nào cũng được.

