← Về danh sách bài viết

Kỹ thuật · Next.js 16 · i18next

Một chữ, ba ngôn ngữ

Việc dịch một nút bấm thoạt nghe có vẻ đơn giản: tra từ điển, rồi đổi chữ. Nhưng khi trang web được dựng lên (render) đến hai lần — một lần ở máy chủ và một lần dưới trình duyệt — thì câu hỏi "đang dùng ngôn ngữ nào" bỗng xuất hiện hai câu trả lời hoàn toàn trái ngược. Và mọi bài toán i18n thực sự hóc búa đều bắt nguồn từ chính điểm này.

EN Banking built on a real core. 42,8 KB
VI Ngân hàng dựng trên một core thật. 49,6 KB · +16%
RU Банкинг на основе настоящего ядра. 61,6 KB · +44%

Đây là cùng một câu, trích nguyên văn từ file locales/{en,vi,ru}/landing.json trong dự án. Cột bên phải thể hiện kích thước thực tế của file translation.json tương ứng với mỗi ngôn ngữ — bản tiếng Nga dài hơn bản tiếng Anh tới 44%. Đó là minh chứng đầu tiên cho thấy i18n không đơn thuần chỉ là việc dịch chữ.

~35 phút đọc 10 phần Next.js 16.2 · React 19 · i18next 24

Bài viết này bắt đầu từ những khái niệm gốc rễ — lý do i18n lại có cách viết tắt kỳ lạ, Unicode sinh ra để giải quyết vấn đề gì — rồi đi thẳng vào một hệ thống thực tế: phần frontend của WAY4 Connect, một ứng dụng web kết nối với hệ thống core-banking WAY4 qua GraphQL, chạy Next.js 16 App Router với ba ngôn ngữ Anh, Việt, Nga. Trọng tâm của bài viết nhắm vào bài toán hóc búa và ít tài liệu hướng dẫn nhất: i18n phải hoạt động thế nào khi cùng một component được render trên máy chủ, rồi lại được dựng (hydrate) một lần nữa trong trình duyệt.

Phần 1

i18n là gì, và vì sao lại có cách viết tắt kỳ lạ?

Khái niệm Internationalization được viết tắt thành i18n đơn giản vì giữa chữ cái i và chữ cái n có đúng 18 ký tự. Kiểu viết tắt này được gọi là numeronym, khởi nguồn từ hãng máy tính DEC vào thập niên 1970–80 — tương truyền xuất phát từ một nhân viên tên Jan Scherpenhuizen có họ dài tới mức đồng nghiệp đành đặt luôn username của ông là S12n.

Viết tắtĐầy đủÝ nghĩa
i18nInternationalizationThiết kế phần mềm để có khả năng hỗ trợ đa ngôn ngữ
L10nLocalizationThực thi việc thêm một ngôn ngữ hoặc một vùng cụ thể
g11nGlobalizationi18n + L10n + chiến lược tiếp cận thị trường
a11yAccessibilityKhả năng tiếp cận (dành cho mọi đối tượng người dùng)

Cách phân biệt dễ nhớ nhất là tự hỏi ai làmlàm bao nhiêu lần. Làm i18n là công việc của kỹ sư phần mềm và chỉ làm một lần duy nhất — đó chính là xây dựng kiến trúc. Còn L10n là công việc của biên dịch viên và sẽ phải làm mãi mãi — cứ mỗi ngôn ngữ mới lại phải làm một lần. Nếu làm i18n không tử tế, mỗi lần thực hiện L10n lại phải sửa code. Đó chính là định nghĩa của sự thất bại.

Dịch chữ chỉ chiếm 40% khối lượng công việc

Đây là hiểu lầm phổ biến nhất mà nhiều người mắc phải. Dưới đây là mười hai nhóm vấn đề mà một hệ thống i18n nghiêm túc phải xử lý — trong đó "dịch chữ" chỉ là một phần nhỏ.

NhómVí dụ cụ thể
Văn bảnDịch thuật, ký tự đặc biệt, escape HTML
Chữ số1,000.50 (Anh) · 1.000,50 (Việt, Đức) · 1 000,50 (Nga, Pháp)
Tiền tệVị trí ký hiệu, khoảng trắng, số thập phân — JPY (Yên Nhật) và VND (Đồng Việt Nam) không có phần thập phân, KWD (Dinar Kuwait) lại có tới ba chữ số thập phân
Ngày giờ08/24/2026 (Mỹ) · 24/08/2026 (Việt) · 24.08.2026 (Nga)
Múi giờChênh lệch giờ (Offset), giờ mùa hè (DST), các loại lịch không phải Gregorian
Số nhiềuViệt, Nhật, Trung: 1 dạng · Anh: 2 dạng · Nga: 4 dạng · Ả Rập: 6 dạng
Giống ngữ pháp«пользователь создал» / «создала» — động từ tiếng Nga phải chia theo giới tính
Hướng văn bảnTrái sang phải (LTR) · Phải sang trái (RTL) (Ả Rập, Do Thái) · Dọc (CJK)
Quy tắc sắp xếpä xếp sau chữ z ở Thuỵ Điển nhưng lại nằm ngay sau a ở Đức; tiếng Việt xếp chữ Đ sau chữ D
Độ giãn nở của văn bảnĐo lường thực tế trong dự án: en 42,8 KB → vi 49,6 KB → ru 61,6 KB
Dữ liệu độngTên loại thẻ ngân hàng do admin tự nhập trên hệ thống — không thể lưu cố định trong file dịch
Định dạng dữ liệu đầu vàoNgười dùng gõ 1.000.000 hay 1,000,000? Chuyển đổi (parse) sai là sai lệch số tiền
Điểm neo

Ba nhóm cuối cùng là những bài toán mà các thư viện dịch thuật không thể giải quyết thay bạn. Chúng ta sẽ quay lại thảo luận chi tiết từng vấn đề ở Phần 5 — dự án này đã có lời giải thích đáng cho cả ba.

Phần 2

Nguồn gốc: Mọi thứ đã được phát minh xong từ trước năm 2000

Việc tìm hiểu lịch sử của i18n không phải là để kể chuyện phiếm. Nó giúp bạn hiểu thấu đáo vì sao các API hiện tại lại có hình hài như vậy — những khái niệm như t(), locale, fallback, hay plural thực chất đều là hậu duệ trực tiếp của những ý tưởng ra đời từ ba, bốn thập kỷ trước.

1963–1980 · Thời kỳ máy tính chưa biết đa ngôn ngữ

Bảng mã ASCII ra đời năm 1963: sử dụng 7 bit để chứa 128 ký tự, vừa vặn đủ cho tiếng Anh nhưng hoàn toàn không có chỗ cho các ký tự é, ă, я, hay . Hệ quả là mỗi quốc gia đành tự chế ra một bảng mã cho riêng mình. Ở Việt Nam, các bảng mã VNI, TCVN3, VISCII, VPS từng tồn tại song song — chỉ cần mở file sai bảng mã là chữ trên màn hình ngay lập tức biến thành "tiếng ngoài hành tinh". Bất kỳ ai từng làm việc với văn bản tiếng Việt những năm 2000 chắc chắn đều nhớ rõ cảm giác đó.

1984–1988 · Sự ra đời của Locale và gettext

Vào năm 1988, chuẩn ANSI C đã đưa hàm setlocale() vào ngôn ngữ lập trình, đi kèm với các bộ định dạng LC_NUMERIC, LC_MONETARY, LC_TIME, và LC_COLLATE. Đây là mốc son đánh dấu lần đầu tiên khái niệm "locale" trở thành một tiêu chuẩn kỹ thuật — và đối tượng Intl của JavaScript hôm nay chính là hậu duệ thừa kế trực tiếp từ ý tưởng đó.

Cùng thời điểm, Sun Microsystems rồi tới GNU đã cho ra đời gettext, khai sinh ra định dạng .po/.mo cùng hàm bọc chuỗi kinh điển _("text"). Đó chính là tổ tiên của hàm t() mà các lập trình viên vẫn gõ hằng ngày.

1987–1993 · Kỷ nguyên Unicode

Năm 1987, ba kỹ sư tại Xerox và Apple đã khởi xướng tầm nhìn về "một bảng mã chung cho mọi hệ thống chữ viết trên Trái Đất". Unicode Consortium chính thức thành lập năm 1991; đến năm 1993 thì hợp nhất hoàn toàn với tiêu chuẩn ISO/IEC 10646 đang được phát triển song song, giúp thế giới tránh được thảm hoạ hai tiêu chuẩn kỹ thuật cạnh tranh nhau.

17.0Phiên bản Unicode hiện hành (09/2025)
159.801Ký tự đã được mã hoá
172Hệ thống chữ viết được hỗ trợ
>98%Trang web sử dụng UTF-8

1992–2008 · Sự thống trị tuyệt đối của UTF-8

Ken Thompson và Rob Pike đã thiết kế ra UTF-8 vào năm 1992 — giai thoại kể rằng họ phác thảo bảng mã này ngay trên một tấm lót ly ở quán ăn tại New Jersey. RFC 3629 chuẩn hoá UTF-8 vào năm 2003, và đến khoảng năm 2008, nó đã vượt mặt mọi bảng mã khác trên internet. Có ba lý do giải thích cho chiến thắng này:

  • Tương thích ngược với ASCII đến từng byte: mọi file ASCII cũ tự nhiên đã là một file UTF-8 hợp lệ. Không cần bất kỳ công đoạn chuyển đổi (migrate) nào.
  • Khả năng tự đồng bộ (Self-synchronizing): dù bị mất đi một byte do lỗi đường truyền, hệ thống vẫn có thể dò lại ranh giới của ký tự kế tiếp, nhờ vào mẫu bit đặc trưng phân biệt byte khởi đầu và byte theo sau.
  • Không phụ thuộc vào Endianness: không cần đến ký tự BOM (Byte Order Mark) để phân định thứ tự byte, chấm dứt khái niệm "UTF-8 LE" hay "UTF-8 BE" phức tạp.

1999–nay · ICU, CLDR và kỷ nguyên của JavaScript

Thư viện ICU và kho dữ liệu CLDR (chứa thông tin locale cho khoảng 700 khu vực) là hai trụ cột nền tảng mà mọi thư viện i18n chuyên nghiệp đều phải dựa vào. Đến năm 2011, i18next ra mắt; năm 2012, đối tượng Intl chính thức được đưa vào tiêu chuẩn ECMAScript; năm 2014 đánh dấu sự xuất hiện của react-intl, và sang những năm 2020 là sự bùng nổ của Lingui, next-intl, hay Paraglide.

Điều đáng nói

Gần như toàn bộ các khái niệm mà chúng ta dùng hôm nay — từ t(), locale, plural, cho tới fallback — đều đã được định hình xong xuôi từ trước năm 2000. i18next không phát minh ra điều gì mới mẻ; nó chỉ đóng gói khéo léo lại tư tưởng của gettextICU để phục vụ cho hệ sinh thái JavaScript. Nắm được điều này, bạn đọc tài liệu của bất kỳ thư viện i18n nào cũng sẽ thấy quen thuộc.

Phần 3

Nền tảng kỹ thuật bắt buộc phải nắm

Unicode: Ba tầng khái niệm dễ nhầm lẫn

Khi làm i18n cho tiếng Việt, sớm hay muộn bạn cũng sẽ va vấp phải vấn đề này. Dữ liệu văn bản thực chất được phân thành ba tầng khác nhau mà nhiều người thường gộp chung làm một:

Ba tầng cấu thành nên một "ký tự"
Grapheme cluster   "cái người dùng nhìn thấy là 1 ký tự"     👨‍👩‍👧   hoặc   ế
        ↓
Code Point         "địa chỉ mã hoá trong bảng Unicode"       U+1F468 U+200D U+1F469 …
        ↓
Code Unit          "đơn vị lưu trữ thực tế trên đĩa cứng"    byte (UTF-8) / 16-bit (UTF-16)

Hệ quả trong JavaScript khiến không ít lập trình viên bỡ ngỡ:

'😀'.length                 // 2  ← JS đếm số lượng code unit của UTF-16, không đếm ký tự
[...'😀'].length            // 1
'👨‍👩‍👧'.length               // 8  ← 1 grapheme = 3 emoji + 2 ký tự nối (Zero Width Joiner)

// Cách chuẩn xác để đếm số ký tự dựa theo những gì người dùng nhìn thấy:
[...new Intl.Segmenter('vi', { granularity: 'grapheme' }).segment('👨‍👩‍👧')].length  // 1

Chuẩn hoá (Normalization) — Vấn đề sống còn với tiếng Việt

const a = 'ế';   // NFC: Gộp chung thành 1 code point duy nhất, U+1EBF
const b = 'ế';   // NFD: Tách rời e + dấu mũ + dấu sắc = 3 code point riêng biệt

a === b                                     // false — mặc dù hiển thị trên màn hình giống hệt nhau!
a.normalize('NFC') === b.normalize('NFC')   // true — cách so sánh đúng chuẩn
Cạm bẫy thực tế

Hãy tưởng tượng người dùng nhập họ tên tiếng Việt rồi lưu vào cơ sở dữ liệu. Thiết bị macOS gõ ra định dạng NFD, trong khi Windows gõ ra định dạng NFC. Nếu bạn không gọi hàm normalize('NFC') để đồng nhất trước khi so sánh hay tìm kiếm, thì chuỗi "Nguyễn" sẽ không bao giờ tìm ra "Nguyễn". Đây là một bug i18n cực kỳ kinh điển trong các hệ thống ngân hàng, và nó ẩn mình hoàn hảo cho tới cái ngày có khách hàng phàn nàn.

BCP 47: Locale hoàn toàn không phải là ngôn ngữ

Locale = ngôn ngữ + (hệ chữ viết) + (khu vực) + (biến thể). Tiêu chuẩn định dạng này có tên là BCP 47.

Mã thẻ (Tag)Ý nghĩa
en-US / en-GBAnh-Mỹ / Anh-Anh — khác biệt về định dạng ngày tháng, chính tả, và tiền tệ
zh-Hans-CN / zh-Hant-TWTiếng Trung giản thể / tiếng Trung phồn thể
sr-Cyrl-RS / sr-Latn-RSCùng một ngôn ngữ, nhưng khác chữ viết — người Serbia dùng cả hệ chữ Kirin lẫn hệ chữ Latin
ar-EG-u-nu-arabTiếng Ả Rập chuẩn Ai Cập, ép hiển thị chữ số Ả Rập ٠١٢٣ thay vì chữ số phương Tây 0123

Dự án này chủ đích sử dụng mã thẻ ngôn ngữ rút gọn — en, vi, ru — để giữ cho URL được ngắn gọn (ví dụ: /vi/dashboard thay vì /vi-VN/dashboard). Thế nhưng, đối tượng Intl lại bắt buộc phải có thẻ đầy đủ mới xác định được quy ước của từng vùng. Do đó, tôi dùng một bảng ánh xạ làm cầu nối:

src/shared/utils/format/intl.ts
export const LOCALE_TAG: Record<AppLanguage, string> = {
  en: 'en-US',
  vi: 'vi-VN',
  ru: 'ru-RU',
};

Luật số nhiều: Khi i18n lộ rõ bản chất là một bài toán ngôn ngữ học

Chuẩn CLDR phân loại quy tắc số nhiều thành sáu nhóm: zero, one, two, few, many, và other. Chẳng có ngôn ngữ nào sử dụng trọn vẹn cả sáu dạng — mỗi ngôn ngữ chỉ áp dụng một tập con nhất định.

Ngôn ngữSố dạngCác dạng được áp dụng
Việt, Nhật, Trung, Hàn, Thái1other — danh từ không bị biến đổi theo số lượng!
Anh, Đức, Tây Ban Nha2one, other
Nga, Ukraina, Séc4one, few, many, other
Ả Rập6Dùng đủ cả sáu dạng

Đi sâu vào tiếng Nga — ngôn ngữ được hỗ trợ trong dự án này:

ru/translation.json
{
  "item_one":   "{{count}} предмет",     // 1, 21, 31…  (các số tận cùng là 1, ngoại trừ 11)
  "item_few":   "{{count}} предмета",    // 2–4, 22–24… (các số tận cùng từ 2–4, ngoại trừ 12–14)
  "item_many":  "{{count}} предметов",   // 0, 5–20, 25–30…
  "item_other": "{{count}} предмета"     // 1,5 — dành cho số thập phân
}

Một điều thú vị là i18next hoàn toàn không tự định nghĩa luật số nhiều. Thư viện này thông minh gọi thẳng API có sẵn của trình duyệt, vốn đã được tích hợp sẵn dữ liệu chuẩn từ CLDR:

new Intl.PluralRules('ru').select(7)   // → Kết quả: "many"
new Intl.PluralRules('vi').select(7)   // → Kết quả: "other"

Ba quy tắc vàng khi xử lý chuỗi văn bản

1. Tuyệt đối không nối chuỗi

// SAI — trật tự từ vựng của mỗi ngôn ngữ là khác nhau, ghép lại theo cách này chắc chắn sai ngữ pháp
<p>{t('agree')} <a href="/terms">{t('terms')}</a></p>

// ĐÚNG — gom trọn vẹn một câu thành một khoá, để phần đánh dấu thẻ (markup) nằm gọn trong bản dịch
<Trans i18nKey="agreement">Tôi đồng ý với <a href="/terms">điều khoản</a>.</Trans>

2. Tắt tính năng tự động escape khi dùng chung với React

Mặc định, i18next tự động escape các ký tự HTML trong giá trị được chèn vào để chống XSS. Nhưng bản thân React cũng đã escape mọi thứ rồi. Việc escape chồng chéo hai lần sẽ khiến trình duyệt hiển thị A &amp; B thay vì A & B. Bạn phải thiết lập cấu hình interpolation: { escapeValue: false } — và nhớ đặt ở cả hai môi trường client lẫn server, nếu không sự sai lệch về cấu hình sẽ khiến quá trình render sinh ra hai kết quả bất đồng.

3. Phân chia Namespace theo Module

Namespace đóng vai trò như một "không gian tên" biệt lập — ý tưởng này tương tự như việc gán tiền tố wsin: trong các thẻ SOAP mà dự án này đẩy sang hệ thống WAY4. Trong kiến trúc i18next, một namespace tương ứng với một file từ điển (JSON).

NamespaceFile tương ứngChứa dữ liệu gì
translationtranslation.jsonVăn bản của ứng dụng: bảng điều khiển, biểu mẫu, các câu thông báo lỗi
landinglanding.jsonNội dung quảng bá (marketing) và các thẻ meta phục vụ SEO

Có ba lý do chính đáng để phân tách namespace: Tối ưu băng thông tải (Code-splitting) (trang chủ landing page không có lý do gì phải gánh thêm 42 KB văn bản của trang dashboard), ngăn ngừa xung đột khoá (từ title của trang landing mang ngữ cảnh khác title của trang dashboard), và chia nhỏ luồng công việc (file marketing gửi cho đội marketing, file app dành cho dev).

Phần 4 · Trọng tâm

Bài toán cốt lõi: Sự đồng bộ giữa Server và Client

Đây là góc khuất mà hầu hết các bài hướng dẫn về i18n đều lướt qua, và cũng là cái hố đen ngốn của lập trình viên nhiều ngày debug nhất. Nếu bạn chỉ rảnh để đọc một phần duy nhất trong bài viết này, hãy đọc phần này.

Trước tiên: Phải hiểu Hydration là gì

HTML mà máy chủ đẩy về trình duyệt giống như một bức ảnh chụp toàn cảnh một căn phòng đã được trang hoàng xong xuôi. Trình duyệt dán bức ảnh đó lên tường — người dùng lập tức nhìn thấy nội dung mà chưa cần tải xong bất kỳ dòng JavaScript nào.

Thế nhưng, một bức ảnh thì không thể tương tác được. Chẳng có cái nút nào mở được popover, chẳng ô input nào gõ được chữ. Để thổi sự sống cho căn phòng ảo đó, React phải lúi húi đi nối dây điện cho từng chiếc công tắc: gắn hàm onClick, khởi tạo useState, và đăng ký các useEffect.

Vấn đề là, muốn nối đúng dây vào đúng công tắc, React bắt buộc phải biết chính xác thẻ <button> thứ ba trong cây DOM đang tương ứng với component nào, mang những props gì, xử lý bằng hàm nào. Trớ trêu thay, thông tin đó hoàn toàn không tồn tại trong chuỗi HTML tĩnh — chúng đã bị vứt bỏ trong quá trình serialize. Cách duy nhất để React lấy lại được khối dữ kiện đó là bắt trình duyệt chạy lại y nguyên đoạn code đã từng sinh ra trang web trên máy chủ.

Quá trình đó gọi là hydration (nạp thủy/dựng hình). Và bởi thế, lần render thứ hai trên trình duyệt là bước bắt buộc — chứ không phải do thiết kế tồi. Mọi chuyện sẽ êm đẹp cho tới khi xuất hiện hệ quả chí mạng: Nếu lần render thứ hai trên trình duyệt lỡ sinh ra một căn phòng có cấu trúc khác với căn phòng trong "bức ảnh" HTML ban đầu, React sẽ cắm nhầm dây. React đủ thông minh để nhận ra điều đó, nó đành ngậm ngùi vứt bỏ toàn bộ HTML mà máy chủ đã cất công gửi đến và tự dựng lại giao diện từ con số không. Đó chính là thảm hoạ mang tên hydration mismatch.

Server Component Client Component render (server) 1 lần duy nhất await HTML cuối cùng không sửa được nữa Trình duyệt + Googlebot render lần 1 trên máy chủ (SSR) HTML tạm người dùng thấy ngay render lần 2 hydrate, trong trình duyệt phải giống hệt nhau lệch một chữ = hydration mismatch
Server Component render một lần — kết quả sinh ra là HTML cuối cùng, không có lần thứ hai để tự sửa sai. Client Component render hai lần — một lần trên máy chủ để có khối HTML thô gửi đi ngay lập tức, và một lần nữa trên trình duyệt để đấu nối các sự kiện tương tác. Toàn bộ độ khó của i18n hội tụ ở mũi tên đứt nét: hai lần render đó bắt buộc phải cho ra đúng cùng một chuỗi ký tự.

Tại sao i18n lại dễ dính "đòn" nặng hơn bất cứ thứ gì khác?

Phần lớn các component đều render ra kết quả giống nhau ở cả hai môi trường vì cấu trúc của chúng chỉ phụ thuộc đơn thuần vào dữ liệu đầu vào (props). Ví dụ, một thẻ <Button>Gửi</Button> thì chạy ở đâu cũng sẽ nhả ra chuỗi <button>Gửi</button>.

Tuy nhiên, component dính dáng tới i18n lại khác biệt hoàn toàn. Kết quả của nó phụ thuộc tuyệt đối vào câu hỏi "chúng ta đang dùng ngôn ngữ nào" — và hai môi trường lại có những cách giải đáp câu hỏi này hoàn toàn khác nhau:

Nguồn cung cấp thông tinMáy chủ có đọc được không?Trình duyệt có đọc được không?
params — đoạn locale nằm trong đường dẫn URL
document.cookieKhông
navigator.languageKhông
localStorageKhông

Bạn thấy đấy, ba trong số bốn nguồn thông tin chỉ tồn tại ở một phía. Hễ vô tình lấy dữ liệu từ một trong ba nguồn bị lệch đó là lập tức hai lần render sẽ trả lời khác nhau — và bùm, mismatch chắc chắn xảy ra, không trượt phát nào.

Nguy hiểm hơn, điều này không phải là giả thuyết trên giấy. Nó ẩn nấp sẵn bên trong chính bản thân thư viện: cơ chế tự động phát hiện ngôn ngữ (detector) của i18next dựa dẫm vào window.location, document.cookienavigator.language — cả ba thứ này đều bốc hơi khi chạy trên máy chủ. Thành thử, dùng chung một file cấu hình, chạy ở hai nơi, sẽ cho ra hai kết quả lệch pha.

Chìa khoá giải mã: Hãy biến locale thành một hàm thuần tuý của URL

Bạn không thể trốn tránh việc render hai lần. Nhưng bạn hoàn toàn có quyền kiểm soát để hai lần render đó luôn cho ra kết quả tương đồng — bằng cách ép cả hai môi trường phải đọc từ cùng một nguồn dữ liệu đầu vào. Nguồn cấp dữ liệu này phải thoả mãn cùng lúc ba điều kiện khắt khe: luôn hiện diện ở cả hai phía, có khả năng đọc được đồng bộ, và không cần phụ thuộc vào JavaScript.

Chỉ có đúng một ứng cử viên duy nhất vượt qua bài kiểm tra: đoạn locale nằm ngay trong đường dẫn URL.

Trên máy chủ
const { locale } = await params;
// Kết quả: 'vi'

Lấy giá trị từ params mà framework Next truyền vào. Không cần JS, không đòi hỏi cookie, dữ liệu sẵn sàng ngay thời điểm render.

Trong trình duyệt
const { locale } = useParams();
// Kết quả: 'vi'

Lấy giá trị từ hook useParams() của Next. Xử lý đồng bộ, dữ liệu có mặt ngay tại lượt render đầu tiên.

Tuy hai API có vẻ ngoài khác nhau, nhưng gốc rễ đều truy xuất từ cùng một đoạn URL. Đây chính là bí mật tối thượng giúp triệt tiêu hoàn toàn lỗi hydration mismatch, gói gọn trong một câu văn.

Điểm mấu chốt của cả bài viết

Kiến trúc này không cố tình trốn tránh hai lần render — vì đó là chuyện bất khả thi. Nhiệm vụ của nó là ép cho hai lần render không thể nào khác nhau được. Và khi đã đạt được cảnh giới đó, sự phức tạp phiền nhiễu sẽ biến mất khỏi tầm mắt: bạn cứ vô tư gõ t('auth.login') như bình thường, chẳng cần mảy may quan tâm mình đang đứng ở lượt render nào.

Tại sao không cắt gọt bớt một lần render cho nhẹ nợ?

Bạn hoàn toàn có quyền đưa ra quyết định chỉ render một lần. Có hai hướng đi, nhưng hướng nào cũng bắt bạn phải hy sinh những tính năng sống còn mà một sản phẩm thương mại khao khát.

Cắt render lần 1 — Thuần Client

Cực kỳ đơn giản: dẹp bỏ SSR, khỏi cần hydrate, và hiển nhiên không bao giờ gặp mismatch. Nhưng cái giá phải trả:

  • Trang web trắng toát cho tới khi file JS tải xong
  • Googlebot đi vào và chỉ thấy một thẻ <div> trống trơn
  • Copy link dán lên Zalo, Facebook → hệ thống không load được tiêu đề, cũng chẳng có dòng mô tả
Cắt render lần 2 — Thuần Server

Kiến trúc vô cùng gọn gàng. Nhưng bạn sẽ mất sạch tính tương tác:

  • Nút nhấn mở popover chọn ngôn ngữ bị liệt
  • Thanh sidebar không tự động mở rộng khi hover chuột
  • Tính năng validate biểu mẫu tại chỗ hoàn toàn tê liệt

Việc phải chạy render đến hai lần chính là cái giá bắt buộc nếu bạn vừa tham lam muốn cả hai — một giao diện HTML có sẵn văn bản chuẩn SEO một trang web đầy đủ nút bấm tương tác. Chẳng có bữa trưa nào miễn phí trong ngành kỹ thuật cả. Tuy nhiên, điều kỳ diệu mà React Server Components (RSC) mang lại chính là thu hẹp vùng bị tổn thương: giờ đây chỉ những component thật sự cần tính năng tương tác mới bị ép phải render hai lần.

Phần 5

Cấu trúc hệ thống i18n trong dự án thực tế

Toàn bộ hệ sinh thái i18n của dự án được thu vén gọn gàng trong vỏn vẹn mười file code. Bốn trụ cột cốt lõi đều được đặt tại src/shared/i18n/, và điểm tinh tế đáng chú ý là chúng được quy hoạch theo thời điểm thực thi, chứ không phải theo nhóm tính năng.

Tên fileThực thi ở môi trường nàoNhiệm vụ giải đáp câu hỏi gì
settings.ts Chạy trên cả ba: Edge, máy chủ, và trình duyệt Hệ thống hỗ trợ những ngôn ngữ nào? Biến lưu Cookie tên là gì?
i18n.ts Chạy dưới trình duyệt Instance i18next dùng chung cho toàn bộ Tab trình duyệt được đặt ở đâu?
i18nServer.ts Chạy trên máy chủ Request hiện tại cần một instance hoàn toàn độc lập — hãy cấp phát cho tôi!
i18next.d.ts Hoạt động lúc biên dịch (compile-time) Những khoá (key) nào được coi là hợp lệ khi gọi hàm t()?
Nguyên tắc cốt lõi

Bốn file cấu hình này được phân chia nghiêm ngặt dựa theo thời điểm thực thi, không phải phân chia theo tính năng. Cố tình trộn lẫn chúng lại với nhau sẽ gây sụp đổ hệ thống — và phần nào bị trộn thì sẽ hỏng đúng theo đặc tính của phần đó: nếu trộn client với server, lỗi rò rỉ ngôn ngữ chéo giữa các request sẽ xuất hiện; nếu trộn môi trường compile-time với runtime, tính năng type-safety (kiểm tra kiểu) sẽ bốc hơi.

settings.ts — Khởi nguồn của sự thật

File này hoàn toàn độc lập, không import bất cứ tài nguyên nào từ ba file còn lại. Vì nằm yên bình ở đáy cây phụ thuộc, nó có thể được import từ bất kỳ đâu — kể cả ở môi trường Edge khắc nghiệt nơi middleware ngự trị, vốn hoàn toàn xa lạ với đối tượng window hay các API hệ thống của Node.

src/shared/i18n/settings.ts
export const fallbackLanguage: AppLanguage = 'en';
export const appLanguages: AppLanguage[] = [fallbackLanguage, 'vi', 'ru'];

// Hằng số DUY NHẤT định nghĩa tên cookie — quy định chung cho CẢ HAI đầu:
//   i18n.ts  → detection.lookupCookie  (phụ trách GHI)
//   proxy.ts → request.cookies.get()   (phụ trách ĐỌC)
export const cookieLanguageKey = 'i18next';

export function isAppLanguage(value: string | null | undefined): value is AppLanguage {
  return typeof value === 'string' && (appLanguages as readonly string[]).includes(value);
}

export function getOptions(lang = fallbackLanguage, namespace: string | string[]): InitOptions {
  return {
    defaultNS: defaultTranslationNamespace,
    fallbackLng: fallbackLanguage,
    lng: lang,
    ns: namespace,
    supportedLngs: appLanguages,
    load: 'languageOnly',                    // Chuyển đổi 'vi-VN' → 'vi'
    interpolation: { escapeValue: false },   // BẮT BUỘC PHẢI khớp với file i18n.ts
  };
}

Một chi tiết thiết kế nhỏ nhưng đầy sức mạnh: phần tử đầu tiên của mảng appLanguages không bị gõ cứng là 'en' mà trỏ tới tham chiếu fallbackLanguage. Nhờ đó, nếu muốn đổi ngôn ngữ mặc định của cả dự án, bạn chỉ cần sửa một dòng duy nhất, và cấu trúc này đảm bảo chắc chắn ngôn ngữ dự phòng luôn luôn nằm trong danh sách hợp lệ — bằng không, i18next sẽ phũ phàng từ chối nạp chính cái ngôn ngữ dự phòng do nó sinh ra.

Hai instance i18next với hai vòng đời trái ngược

Máy chủ · i18nServer.ts

Factory Pattern — Cấp phát một instance mới tinh cho từng request.

Một tiến trình Node trên máy chủ phải nai lưng phục vụ nhiều người dùng cùng lúc. Nếu dùng chung một instance i18next, một request đổi sang tiếng Nga qua hàm changeLanguage('ru') sẽ bất thình lình làm đổi luôn ngôn ngữ của một request khác (vốn đang yêu cầu tiếng Việt) đang được render dang dở.

Khối code này có từ khóa await, và hoàn toàn không gắn cơ chế tự động phát hiện ngôn ngữ (detector).

Trình duyệt · i18n.ts

Singleton Pattern — Duy trì một instance duy nhất cho cả một tab trình duyệt.

Trong thế giới của client, một tab trình duyệt tương đương với một người dùng, và chỉ hiển thị một ngôn ngữ duy nhất tại một thời điểm. Mối nguy ô nhiễm dữ liệu chéo không tồn tại, bù lại ta thu hoạch được bộ nhớ đệm (cache) cực kỳ giá trị: khi người dùng đảo qua lại giữa vi ↔ en, hệ thống chỉ cần tải file ngôn ngữ từ mạng đúng một lần.

Không dùng await, và bắt buộc phải tích hợp cơ chế tự động phát hiện ngôn ngữ (detector).

Race condition (Tình trạng tương tranh) không cần đa luồng

JavaScript quả thực là ngôn ngữ chạy đơn luồng (single-thread), nhưng nó lại có thói quen nhường lượt thực thi ở mỗi lệnh await. Trong lúc request A đang kiên nhẫn chờ hệ thống WAY4 phản hồi dữ liệu, event loop sẽ tranh thủ chạy đoạn code của request B. Nếu B vô tình làm thay đổi một biến toàn cục, thì khi A tỉnh dậy, nó sẽ hoang mang đọc nhầm phải giá trị mới của B. Thực tế, chỉ cần có sự hiện diện của await và một biến dùng chung là quá đủ để kích hoạt thảm họa đua tranh — đó chính là lý do vì sao hàm createInstance() hoạt động phía máy chủ hoàn toàn không phải là một sự lựa chọn tuỳ ý.

useTranslation — Nhịp đập trung tâm của hệ thống kiến trúc

Đoạn custom hook vỏn vẹn 20 dòng này chính là nơi nguyên tắc tối thượng "locale là hàm thuần của URL" được ép buộc thực thi.

src/shared/hooks/use-translation.ts
export function useTranslation(namespace?: keyof I18nNamespaces | Array<keyof I18nNamespaces>) {
  const { locale } = useParams<{ locale: string }>();

  // Bất kỳ route nào lọt ra ngoài mảng [locale] sẽ không nhận được param này, và dĩ nhiên các giá trị locale rác trên URL
  // cũng phải bị chặn lại trước khi truyền thẳng xuống engine của i18next.
  const language: AppLanguage = isAppLanguage(locale) ? locale : fallbackLanguage;

  const { t, i18n } = useI18nTranslation(namespace, { lng: language });

  return { t, i18n, changeLanguage, locale: language };
}

Đoạn truyền tham số lng: language chính là điểm mấu chốt. Nếu mạnh dạn xóa nó đi, kịch bản sau sẽ xảy ra:

Không ép cứng lngCó ép cứng lng: locale
Hàm t sẽ đọc dữ liệu từ đâui18n.language — state toàn cục của i18nextlocale — Lấy trực tiếp từ URL
Giai đoạn Render trên máy chủTrả về 'en' (vì không có cơ chế tự động phát hiện ngôn ngữ)Trả về 'vi'
Giai đoạn Client, lượt render 1Trả về 'en' (vì effect chưa kịp chạy)Trả về 'vi'
Giai đoạn Client, lượt render 2Trả về 'vi' (sau khi effect đã cập nhật)Trả về 'vi'
Kết quả hiển thịLỗi Mismatch sập nguồn, màn hình bị nháy chữHoàn hảo, cả ba lần render đều cho ra kết quả đồng nhất

Hai bài toán hóc búa mà thư viện dịch thuật bó tay

Bài toán 1: Định dạng chữ số, tiền tệ, và ngày tháng

Sứ mệnh của thư viện dịch thuật chỉ xoay quanh xử lý chuỗi. Việc định dạng chữ số và ngày tháng là chuyên môn của API hệ thống Intl. Và tại đây, có một cái hố tử thần dành riêng cho các hệ thống phần mềm ngân hàng:

// SAI LẦM — Không truyền bất kỳ tham số nào
new Date(createdAt).toLocaleDateString()
//   Môi trường máy chủ Node (chạy múi giờ UTC)      → "8/24/2026"
//   Trình duyệt của khách (chạy múi giờ UTC+7)      → "24/08/2026"
//   → Phát sinh lỗi hydration mismatch; và nếu giao dịch xảy ra sát mốc nửa đêm, THỜI GIAN SẼ BỊ LỆCH HẲN MỘT NGÀY DƯƠNG LỊCH

// GIẢI PHÁP ĐÚNG — Cung cấp locale và múi giờ tường minh rành mạch
new Intl.DateTimeFormat(localeTag(locale), {
  dateStyle: 'medium',
  timeZone: 'Asia/Ho_Chi_Minh',
}).format(date)

Bài toán 2: Nội dung dữ liệu động đổ về từ cơ sở dữ liệu

Thử hình dung: Quản trị viên (admin) thiết lập một loại thẻ tín dụng mới toanh tên là VIP_COMPANY_A trên giao diện bảng điều khiển. File JSON locales/vi/translation.json nằm im lìm trong source code không thể nào tự đẻ ra một khoá (key) đón sẵn cho loại thẻ này — file dịch thì nằm trong repository, còn dữ liệu thì lưu sống trong cơ sở dữ liệu.

Dữ liệu tĩnh (nhãn giao diện)Dữ liệu động (từ database)
Ví dụ"Đăng nhập", "Kiểm tra số dư"Tên thẻ tín dụng, mô tả chương trình ưu đãi
Đối tượng khởi tạoLập trình viên, đội ngũ biên dịch viênQuản trị viên (Admin) thông qua giao diện CMS
Nơi lưu trữFile JSON tĩnh trong kho mã nguồn (repo)Một bảng tên CardTypeTranslation trong cơ sở dữ liệu
Khi muốn thay đổi chữBắt buộc phải triển khai lại (Deploy) bản build mớiĐổi ngay lập tức, không cần đụng đến mã nguồn
Cơ chế lấy dữ liệuGọi hàm t('card.balance')Gọi hàm localizeCardType(type, locale)

Hàm tùy chỉnh localizeCardType chứa đựng một quyết định thiết kế cực kỳ thông minh đáng để học hỏi: nó hạ cấp (fallback) dữ liệu về ngôn ngữ mặc định theo từng trường (field) độc lập, chứ không gom cục hạ cấp toàn bộ đối tượng. Nếu một loại thẻ đã được admin cất công dịch tên thẻ nhưng lại bỏ quên chưa dịch danh sách tính năng ưu đãi, thì giao diện vẫn sẽ hiển thị cái tên thẻ bằng tiếng Việt, thay vì hiển thị toàn bộ khối thông tin bằng ngôn ngữ gốc tiếng Anh. Đó chính là tinh thần nhân bản của cơ chế fallbackLng được kế thừa từ i18next, áp dụng một cách uyển chuyển vào tầng dữ liệu động.

Giới hạn của thư viện dịch thuật

Thư viện i18n sinh ra chỉ để lo liệu những chuỗi ký tự mà lập trình viên đã nhúng cứng vào code. Còn những chuỗi do người dùng hoặc admin nhập vào thì lại là một bài toán thuộc phạm trù thiết kế mô hình cơ sở dữ liệu, và buộc phải được giải quyết tận gốc rễ ở tầng schema — thư viện frontend hoàn toàn vô can.

Phần 6

Luồng thực thi dịch thuật trên máy chủ: Bóc tách từng bước

Hãy cùng theo dõi hành trình của một lời gọi hàm cụ thể: t('landing:hero.title') đang thực thi trên trang /vi, trả về kết quả cuối cùng là dòng chữ "Ngân hàng dựng trên".

Bước 1 — Middleware chốt chặn ở Edge

src/proxy.ts · (Lưu ý: Kể từ Next.js 16, file middleware.ts đã được đổi tên thành proxy.ts)
proxy(request)
├─ PUBLIC_FILE.test('/vi')     // Dùng biểu thức chính quy /\.[^/]+$/ — không tìm thấy dấu chấm → trả về false
├─ '/vi'.includes('/api/')     // false
└─ localeFromPath('/vi')
   └─ Khớp cắt chuỗi '/vi'.split('/') → ['', 'vi'] → Lấy phần tử [1] = 'vi' → Xác nhận hợp lệ
   → Trả về lệnh return undefined  // Tín hiệu "không can thiệp": từ chối redirect, không thiết lập Set-Cookie

Bởi vì đường dẫn URL đã chứa sẵn thông số locale chuẩn xác, proxy quyết định cấp luồng xanh cho request đi thẳng. Các đoạn mã dự đoán ngôn ngữ người dùng và đoạn mã điều hướng (redirect) hoàn toàn nằm im bất động. Điều này mang ý nghĩa cực kỳ to lớn đối với năng lực lưu trữ bộ nhớ đệm (cache) của các hệ thống CDN — việc bạ đâu cũng nhét header Set-Cookie vào tất cả các trang sẽ vô tình làm tê liệt hoàn toàn chức năng cache của CDN.

Bước 2 — Next.js phân bổ định tuyến (routing), gọi component

app/[locale]/page.tsx
export default async function HomePage({ params }) {
  const { locale } = await params;                            // Kết quả: 'vi'
  const { t } = await getServerTranslation(locale, 'landing');
  …
  title={t('landing:hero.title')}

Bước 3 — Hàm getServerTranslation chuẩn hoá đầu vào

src/shared/i18n/i18nServer.ts
const ns = namespace ?? defaultTranslationNamespace;          // Lấy giá trị: 'landing'
const namespaceKey = Array.isArray(ns) ? ns.join(',') : ns;   // Ép kiểu: 'landing'
const instance = await getInstance('vi', 'landing');          // Cả hai biến truyền vào lúc này BẮT BUỘC phải là kiểu CHUỖI
Tại sao khoá (key) lưu vào cache bắt buộc phải là một chuỗi ký tự

Hàm React.cache() so sánh các tham số bằng toán tử nghiêm ngặt ===. Trớ trêu thay, biểu thức ['a','b'] === ['a','b'] trong JavaScript sẽ luôn trả về false — mảng được đánh giá dựa theo địa chỉ tham chiếu vùng nhớ, chứ không dựa theo nội dung bên trong nó. Nếu bạn ngây thơ ném thẳng một mảng namespace vào hàm, thì mỗi lần gọi, nó lại được cấp phát thành một địa chỉ mảng hoàn toàn mới, dẫn đến việc bộ đệm cache không bao giờ phát huy tác dụng. Định lý thiết kế: Khoá cache bắt buộc phải dùng các giá trị nguyên thuỷ (primitive). Nguyên tắc này cũng hoàn toàn đúng khi áp dụng cho các hook như useMemo hay useEffect.

Bước 4 — Khởi chạy React.cache để dò lại lịch sử truy xuất trong lượt render này

Hàm gọiTham số truyền vàoKết quả xử lý
layout.tsxgenerateMetadata('vi','landing')Trượt bộ đệm → kích hoạt chạy factory (tiêu tốn ~5ms)
page.tsxgenerateMetadata('vi','landing')Trúng bộ đệm → trả kết quả 0ms
page.tsxHomePage('vi','landing')Trúng bộ đệm → trả kết quả 0ms
Footer.tsx('vi','translation,landing')Trượt bộ đệm — vì truyền khoá khác
AuthShowcase.tsx('vi','landing')Trúng bộ đệm → trả kết quả 0ms

Tóm lại, React.cache() hoạt động hệt như một tờ giấy nhớ dùng một lần rồi quăng vào sọt rác: nó sẽ gộp (dedupe) các lời gọi hàm trùng lặp trong phạm vi duy nhất một lượt render, và sang đến request tiếp theo, nó lại bắt đầu với một tờ giấy nháp mới tinh. Vừa cung cấp tốc độ phản hồi chớp nhoáng bên trong một request, lại vừa cách ly trạng thái an toàn tuyệt đối giữa các request khác nhau.

Bước 5 — Factory Pattern lắp ráp instance

const i18nInstance = createInstance();      // Khởi tạo store quản lý riêng, đối tượng translator riêng
await i18nInstance
  .use(initReactI18next)
  .use(resourcesToBackend((lng, ns) => import(`./locales/${lng}/${ns}.json`)))
  .init(getOptions('vi', ['landing']));

Lùi sâu vào bên trong phương thức .init(), lúc này i18next bắt đầu đắn đo xem nên nạp file ngôn ngữ nào:

toResolveHierarchy('vi')  →  ['vi', 'en']   // 'en' bất thình lình có mặt vì cấu hình fallbackLng
backendConnector.load(['vi','en'], ['landing'])

  → import('./locales/vi/landing.json')   // Đối tượng Promise
  → import('./locales/en/landing.json')   // Đối tượng Promise
  → store.addResourceBundle(...)  ×2
Chi tiết khuất lấp ít ai mảy may để ý

Thư viện i18next tự động ra lệnh nạp cả bộ ngôn ngữ dự phòng. Một instance đơn lẻ này thực chất phải nai lưng đọc tới hai file json, chứ không phải một. Đó chính là cái giá ẩn phải trả của tính năng fallbackLng — và trớ trêu thay, cũng chính là phao cứu sinh thần kỳ giúp một khoá dù bị thiếu ở bản vi nhưng vẫn hiển thị được đoạn văn bản tiếng Anh chữa cháy thay vì phơi bày trần trụi nguyên một mã khoá (key) vô hồn.

Bước 6 — Bắt buộc phải có từ khoá await

Lệnh import() sử dụng cú pháp đóng ngoặc tròn mang ý nghĩa là một lời gọi hàm ở thời gian chạy (runtime). Việc thao tác đọc file từ ổ đĩa tốn kém thời gian nên nó sẽ nhả về một đối tượng Promise. Nếu cố tình không chờ (không await), hệ thống sẽ nhảy cóc gọi hàm t() vào lúc cuốn từ điển chưa kịp tải về tới nơi — và khi module i18next lục lọi mà không thấy khoá đó đâu, nó sẽ hành xử bằng cách trả về chính cái chuỗi mã khoá thô kệch đó:

t('meta.home.title')
// Nếu tải kịp từ điển  → Trả về: "WAY4 Connect"
// Nếu chưa tải kịp     → Trả về: "meta.home.title"    ← y xì đúc nguyên văn cái mã khoá

Khi sự cố này diễn ra trên môi trường trình duyệt (client), nó tự sở hữu cơ chế chữa lành: từ điển sẽ chạy về tới nơi sau khoảng 40ms, i18next nhả ra một sự kiện (event), kích hoạt React vẽ lại giao diện (re-render), và từ ngữ chuẩn xác lập tức xuất hiện. Người dùng chậm lắm thì chỉ cảm nhận thấy màn hình nháy một cái xẹt, thế là xong.

Thế nhưng trên môi trường máy chủ (server), bi kịch là không hề tồn tại cơ hội thứ hai. Các Server Component bị giới hạn chỉ được vẽ giao diện đúng một lần duy nhất, nhả ra thành phẩm là một chuỗi HTML và tống thẳng qua đường dây cáp mạng. Cái mã khoá thô kệch đó sẽ bị đông cứng vĩnh viễn trong dòng code HTML — và tai hại hơn, đó chính là thứ đập thẳng vào mắt các bộ máy tìm kiếm (Googlebot) khi đi thu thập dữ liệu:

<title>meta.home.title</title>
<meta name="description" content="meta.home.description">

Rất may mắn, môi trường máy chủ nắm trong tay một lợi thế mà client không bao giờ có được: Các Server Component bản chất là những async function, điều đó đồng nghĩa với việc chúng được cấp đặc quyền chờ đợi. Ngược lại, các custom hook trong React thì không bao giờ được phép làm thế — hook useTranslation() bị ép phải nhả về dữ liệu ngay tức khắc.

Bước 7 — Thao tác dò từ điển

t('landing:hero.title')

① extractFromKey   // Xử lý dấu ngăn cách nsSeparator ':' → Tách ra được ns='landing', key='hero.title'
② resolve          // Lên danh sách mã codes = ['vi','en'] — Ưu tiên dò 'vi' trước
③ getResource      // Xử lý dấu ngăn cách keySeparator '.' → Tách ra mảng ['hero','title'] → Đi lần mò xuống cây cấu trúc object
                   // Trỏ trúng: store.data.vi.landing.hero.title
④ interpolate      // Dò quét không thấy các biến cần chèn {{biến}} → Nhả ra nguyên vẹn chuỗi gốc

→ Kết quả: 'Ngân hàng dựng trên'

Giả sử xui xẻo mã khoá bị mất ở nhánh vi, vòng lặp tự động chuyển hướng tìm kiếm sang nhánh en — đó chính là sự ưu việt của cơ chế fallbackLng. Nếu lỡ thiếu ở cả hai ngôn ngữ, lúc này i18next mới bất lực chịu thua và trả về chuỗi mã khoá thô.

Cuối chặng đường, React âm thầm biến toàn bộ cây component thành một chuỗi văn bản HTML nguyên khối và Next.js chịu trách nhiệm stream (chảy) luồng dữ liệu đó về trình duyệt. Chữ nghĩa dịch thuật đã an toạ sẵn bên trong file HTML — nhờ vậy trình duyệt không cần động tay chạy bất kỳ đoạn mã JS nào, Googlebot và mạng xã hội Zalo cũng có thể đọc hiểu nội dung trang tức thì. Và quan trọng nhất là file vi/landing.json không hề bị gửi xuống trình duyệt khách: cuốn từ điển khổng lồ vẫn nằm an toàn trên máy chủ, chỉ có kết quả chắt lọc cuối cùng mới được xuất rạp.

Phần 7

Luồng thực thi dịch thuật trên Client: Bóc tách từng bước

Tiếp theo, chúng ta sẽ lần theo dấu vết của t('auth.login') đang nằm trong component Header trên giao diện /vi, trả về kết quả "Đăng nhập". Sự khác biệt mang tính sống còn ở đây là: Component này bị ép phải render tới hai lần, trên hai hệ thống máy tính hoàn toàn riêng biệt.

PHA 0 Quá trình i18n.ts chạy khởi động — Dùng chung một file, nhưng ra hai kết quả lệch pha Khởi động trên máy chủ: Không tìm thấy đối tượng window → Mặc định về 'en' Khởi động trên trình duyệt: Lấy đường dẫn path '/vi' → Chốt được 'vi' PHA 1 SSR — Lượt render Header trên máy chủ useParams() cho ra = 'vi' → Ép cứng lng: 'vi' → Chạy hàm getFixedT('vi') Kết quả HTML đóng gói gửi đi: <a>Đăng nhập</a> PHA 2 Hydrate — Lượt render lại Header bên trong trình duyệt useParams() tiếp tục cho ra = 'vi' → Ép cứng lng: 'vi' → Chạy hàm getFixedT('vi') Chuỗi "Đăng nhập" === "Đăng nhập" → Đối chiếu khớp hoàn hảo, React chỉ tiến hành đấu nối sự kiện vào nút bấm Bí kíp là cả hai lần render đều buộc phải đọc dữ liệu từ cùng một đoạn URL — vậy nên không thể nào xảy ra sai lệch Cơ chế phát hiện ngôn ngữ vẫn chạy ngầm, nhưng nó đã bị tước quyền quyết định chữ sẽ hiển thị ra màn hình
Ba pha khởi đầu của một luồng xử lý trên client. Pha 0 là mầm mống gây mismatch đang ấp ủ sẵn bên trong bản thân thư viện — dùng chung một file cấu hình nhưng lại nhả ra hai kết quả. Rất may, Pha 1 và Pha 2 đã vô hiệu hoá tận gốc rễ mối hoạ này bằng cách dùng bàn tay sắt ép thuộc tính lng tuân theo đúng URL, nhờ vậy hai vòng render đều phải cắm vào cùng một dữ liệu đầu vào và vĩnh viễn không thể đẻ ra hai kết quả chênh lệch.

Pha 0 — Quá trình Module khởi động, chạy ở hai môi trường sinh ra hai kết quả

File i18n.ts chỉ được khởi chạy một lần duy nhất vào khoảnh khắc nó được import lần đầu — nhưng ngặt nỗi nó lại được hệ thống import ở cả hai môi trường, đồng nghĩa với việc nó âm thầm chạy hai lần ở hai nơi hoàn toàn cách biệt, và trớ trêu thay cơ chế phát hiện ngôn ngữ lại ném ra hai kết quả đá nhau chan chát như biểu đồ minh hoạ ở trên.

Ngay sau đó, hàm .init() bắt tay vào tải xuống cuốn từ điển — nó chạy dưới nền (bất đồng bộ), và người viết code lại chủ tâm đánh dấu void, tức là cố ý không mảy may chờ đợi kết quả. Đâu là lý do người kỹ sư dám mạo hiểm không thèm đợi? Đó là vì trên môi trường client, họ luôn có chiếc "chuông báo" đánh thức. Đây chính là điểm giao thoa tạo nên sự khác biệt lớn nhất so với khi code chạy trên máy chủ.

Pha 1 — Giai đoạn SSR: Công năng của Suspense giải cứu thế cờ

Nếu soi kỹ vào đoạn mã nguồn gốc rễ của thư viện react-i18next:

react-i18next/dist/commonjs/useTranslation.js
const i18n = i18nFromProps || i18nFromContext || getI18n();
//                            ↑ Lấy object được cung cấp từ provider <I18nextProvider> đang gói trong I18nProviderClient

const ready = (i18n.isInitialized || i18n.initializedStoreOnce)
            && namespaces.every(n => hasLoadedNamespace(n, i18n, i18nOptions));
// → Kết quả trả về FALSE ở ngay lượt render khởi động đầu tiên, bởi vì hàm .init() vẫn chưa kịp chạy xong

const memoGetT = useMemoizedT(i18n, props.lng || null, namespaces[0], keyPrefix);
// → Quy trình: i18n.getFixedT('vi', 'translation')  ← Cách vận hành CÙNG CƠ CHẾ hệt như phía máy chủ

if (ready) return ret;
throw new Promise(resolve => {
  if (props.lng) loadLanguages(i18n, props.lng, namespaces, () => resolve());
});

Do thuộc tính useSuspense mặc định luôn bật ở chế độ true, nên thay vì render, component này sẽ bất ngờ ném ngược ra một đối tượng Promise. Cơ chế của React sẽ ra lệnh tạm đình chỉ nhánh giao diện đó lại, chờ hàm loadLanguages tải thành công đúng ngôn ngữ vi, xong xuôi đâu đấy thì kích hoạt vòng vẽ giao diện (render) mới — ở lần vẽ này bộ từ điển đã cầm sẵn trong tay, và lời gọi t('auth.login') sẽ chễm chệ in ra chữ "Đăng nhập".

Bóc trần nguyên do thứ hai vì sao thông số lng lại mang tính sống còn

Hãy nhớ lại rằng, cơ chế phát hiện ngôn ngữ trên môi trường máy chủ đã tự tiện phán đoán ngôn ngữ là 'en'. Nếu bạn lơi lỏng không ép cứng tham số lng, dòng lệnh loadLanguages ở trên sẽ ngây ngô nạp vào gói ngôn ngữ en và chuỗi HTML được đóng gói gửi đi sẽ phơi bày chữ "Login" — để rồi khi client chạy hydrate lại bất ngờ sửa thành chữ "Đăng nhập" gây chớp nháy. Chỉ nhờ có lệnh props.lng = 'vi' chốt cứng mà hệ thống buộc phải nạp gói vi.

Pha 2 — Quá trình Hydrate: Mảnh ghép hoàn hảo

Hoạt động trong trình duyệt, công cụ phát hiện ngôn ngữ truy vết đối tượng window.location.pathname = '/vi' nên ngay từ mốc xuất phát, giá trị i18n.language đã chốt luôn là 'vi'. Component I18nProviderClient tiến hành phép kiểm tra if (i18n.language !== locale) — hai biến hoàn toàn đồng nhất, nên nó chẳng thèm làm gì thêm. Lúc này, tại một luồng chạy bình thường, nhiệm vụ duy nhất còn lại của provider là phân phát context cho các component con.

Đồng thời, mọi component nào có xài hook useTranslation đều tự động giăng mạng lưới theo dõi sự kiện (event):

const [t, setT] = useState(getT);              // ① Khởi tạo hàm t là một tham số STATE chính danh của React

useEffect(() => {
  const boundReset = () => setT(getNewT);      // ③ Khi chuông kêu → kích hoạt lệnh setState → Kích hoạt vòng RE-RENDER
  if (bindI18n) i18n.on(bindI18n, boundReset); // ② Đăng ký kênh lắng nghe tín hiệu 'languageChanged'
  return () => i18n.off(...);                  // ④ Dọn dẹp tháo dỡ kênh đăng ký khi component bị gỡ (unmount)
}, [i18n, joinedNS]);
Mẫu hình thiết kế (Design Pattern) bắt buộc phải nằm lòng

Bản thân React không hề nhọc công theo dõi sự thay đổi biến (variable) của bạn. Dù bạn có can thiệp thay đổi một biến từ bên ngoài, giao diện màn hình sẽ không hề mảy may nhúc nhích — duy nhất chỉ có hàm setState mới đủ uy lực buộc React cầm cọ vẽ lại. Vì thế nên toàn bộ các thư viện nằm ngoài thế giới React (từ Redux, React Query, socket.io, cho tới i18next) đều phải tuân thủ nghiêm ngặt mô thức ba bước: đưa giá trị lưu vào useState, sử dụng useEffect để cắm ăng-ten lắng nghe biến động, và ngay khi có sự kiện xảy ra thì lôi hàm setState ra để cập nhật. Nếu thấu hiểu mẫu hình này, bạn đã nắm trong tay bí quyết nối React với tất tần tật các nền tảng ngoại lai.

Pha 3 — Chuyển đổi ngôn ngữ, và một hệ quả không lường trước

Người dùng bỗng bấm nút đổi ngôn ngữ sang tiếng Nga. Bộ giao diện chọn ngôn ngữ ngay lập tức phát động hai luồng công việc:

src/shared/components/i18n/LanguageSwitcher.tsx
changeLanguage('ru');                          // ①
router.push(`/ru${pathWithoutLocale}`);         // ②
① changeLanguage('ru')
  • Nạp dự phòng file ru/translation.json
  • Gắn đè vào cookie nội dung i18next=ru
  • Phát sóng sự kiện → ra lệnh mọi component chạy render lại

Dù vậy, giao diện chữ hiển thị vẫn kiên quyết giữ lại ngôn ngữ tiếng Việt — nghịch lý này là do thuộc tính props.lng vẫn khư khư bám lấy dữ liệu từ URL, mà lúc này chuỗi URL lại chưa hề thay đổi.

② router.push('/ru/…')
  • Hàm useParams() lúc này nhả ra giá trị mới là 'ru'
  • Danh sách phụ thuộc (Deps) của hàm useMemoizedT phát hiện biến động → ra lệnh tính toán lại
  • Gọi thực thi getFixedT('ru', …)

Phải đến đúng khoảnh khắc này, các chuỗi chữ mới chính thức biến hình — đổi sang "Вход".

Minh chứng thép bẻ gãy mọi tranh luận

Một mình lời gọi hàm changeLanguage bất lực trong việc thay đổi chữ hiển thị trên màn hình. Xét sâu vào trong kết cấu code, state nội bộ quản lý bởi i18next thực chất không hề được trao đặc quyền phán xét chuỗi chữ nào sẽ hiển thị — uy quyền tối thượng đó thuộc về URL. Câu khẩu hiệu "URL là nguồn sự thật" không đơn thuần là một lời tung hô sáo rỗng; nó là nguyên lý vận hành thép mà bạn có thể dễ dàng chứng thực chỉ bằng thao tác click chuột.

Đối chiếu hai luồng thực thi

Môi trường Máy chủ (Server)Môi trường Trình duyệt (Client)
Mô hình triển khaiFactory — khởi tạo instance hoàn toàn mới cho từng requestSingleton — xài chung một instance duy nhất cho toàn bộ tab
Nguồn cung cấp localeLấy từ params.localeLấy từ useParams(), đè quyền cơ chế phát hiện ngôn ngữ
Thao tác chờ từ điểnGắn cờ await — bảo đảm dữ liệu luôn trong tình trạng sẵn sàngSử dụng chức năng Suspense, tiếp đến phát sóng sự kiện
Số lượng vòng lặp Render1 vòng duy nhất — không có ngoại lệNhiều vòng — có cơ chế tự chữa lành
Lời gọi hàm dịch thuậtgetFixedT(lang, ns)getFixedT(props.lng, ns)
Cách ứng xử khi thiếu từ điểnChuỗi mã khoá thô kệch bị đóng băng nguyên vẹn trong khối HTMLMàn hình nháy nhẹ một nhịp rồi chữ chuẩn xác sẽ hiện ra
Tổng số lượng file tham gia xử lý967

Giao điểm tụ hội đáng suy ngẫm nhất của hai luồng thiết kế này: bất luận khởi đầu ra sao, cả hai bên cuối cùng đều phải triệu gọi đến hàm getFixedT(<locale lấy từ URL>, ns). Một bên máy chủ thì rút locale từ params, một bên client thì bóc tách từ useParams() — dù cấu trúc API khác biệt nhưng cả hai lại vục chung một nguồn nước: đoạn URL.

Phần 8

Sự kết hợp giữa URL và Cookie: Mỗi công cụ đảm nhiệm đúng chuyên môn

Một hệ thống phần mềm đa ngôn ngữ (i18n) hoàn chỉnh bắt buộc phải phản hồi thấu đáo ba câu hỏi hóc búa tại ba mốc thời điểm tách biệt:

Mốc thời gianNội dung câu hỏiĐối tượng truy vấn
T1Khi người dùng gõ tên miền site.com lần đầu tiên, hệ thống sẽ tự động dắt tay họ tới phiên bản ngôn ngữ nào?Middleware
T2Trong quá trình máy chủ hì hục render HTML — nó phải dùng ngôn ngữ nào để dịch văn bản?Server Component
T3Trong những lần truy cập kế tiếp của người dùng, bằng cách nào hệ thống tự nhớ được lựa chọn ngôn ngữ của họ trước đó?Trình duyệt

Nhấn mạnh: Chẳng có công cụ nào đủ quyền năng gánh vác cả ba nhiệm vụ một lúc.

Khi dùng độc lập URL

Đáp ứng xuất sắc câu hỏi T2. Đáng buồn thay, ở câu T3 nó hoàn toàn bó tay — bản thân URL không được trang bị bộ não để ghi nhớ dữ kiện:

  • Lần truy cập 1: Người dùng gõ site.com → hệ thống không tìm thấy locale → buộc phải đoán mò → một người dùng ở Việt Nam dùng hệ điều hành Windows thiết lập bằng tiếng Anh sẽ bị quăng thẳng sang trang tiếng Anh
  • Người dùng đành ngậm ngùi tự đổi bằng tay sang tiếng Việt (VI)
  • Lần truy cập 2: Người dùng lại gõ site.com → hệ thống vẫn tiếp tục trò đoán mò → và lại bị vứt sang giao diện tiếng Anh
Khi dùng độc lập Cookie

Giải quyết rất ngọt câu hỏi T1 và T3. Nhưng hậu quả là một chuỗi URL lại đang ngầm phục vụ tới N phiên bản nội dung:

  • Khi Googlebot đi cào dữ liệu, nó đâu có mang theo cookie → hệ quả là nó chỉ cào được đúng một phiên bản trang web
  • Bạn nhiệt tình copy link gửi cho đối tác người Nga, khi họ nhấp vào lại thấy giao diện tiếng Việt chềnh ềnh
  • Các máy chủ CDN thì mặc định cache dựa vào URL → hệ thống sẽ tải bừa phứa và phục vụ sai bét nhè phiên bản ngôn ngữ
Cookie i18next=vi Thao tác đọc proxy.ts Đọc DUY NHẤT một lần ở đây 307 → /vi Thao tác ghi URL /vi/dashboard RANH GIỚI BẢO MẬT: Chu kỳ React bắt đầu từ cột mốc này Khu vực Cookie Hoàn toàn bất khả xâm phạm Máy chủ + Trình duyệt Chỉ ngoan ngoãn đọc từ URL Sứ mệnh của Cookie đã kết thúc trọn vẹn TRƯỚC thời điểm React khởi động máy Chính nhờ vậy mà nó không có mảy may một cơ hội nào để gây ra hiện tượng hydration mismatch
Cookie không bao giờ được phép trực tiếp nhúng tay vào quá trình render — chức năng của nó thuần tuý chỉ là để redirect. Ngay sau khi Middleware đọc cookie xong, nó lập tức đúc thông tin đó vào thân URL rồi mới nhả đường cho request băng qua. Bắt đầu từ khoảnh khắc ấy, chỉ còn chuỗi URL được phép tồn tại trên sân chơi. Đó chính là tấm khiên vững chãi bảo vệ kiến trúc này, chứ không hề là sự lắp ghép chắp vá "hai công cụ tốt gộp lại".

Tại sao chọn Cookie thay vì tin tưởng localStorage

Lời giải đáp nằm gọn ở đúng một chi tiết: tác vụ middleware diễn ra trên máy chủ, thực thi trước khi trình duyệt tải bất kỳ một dòng code JS nào.

t = 0ms     Phía Trình duyệt phát tín hiệu:
              GET / HTTP/1.1
              Cookie: i18next=vi          ← Gói kèm TỰ ĐỘNG, không cần đụng tay đụng chân

t = 1ms     Lệnh proxy.ts tóm gọn header → chuyển hướng 307 → /vi
            ↑ QUYẾT ĐỊNH ĐIỀU HƯỚNG HOÀN TẤT TẠI MỐC NÀY

t = 200ms   Khối HTML đổ về, người dùng thấy hình hài văn bản
t = 400ms   Gói Bundle JS khởi động
t = 401ms   ← Giây phút muộn màng đầu tiên localStorage.getItem() mới móc ra được dữ liệu. Chậm mất 400ms.

Hãy nhớ rằng localStorage không bao giờ tự động rời bến gửi đi đâu cả. Nó chỉ được phép yên vị trên ổ cứng máy tính cá nhân của người dùng, và duy nhất chỉ có các hàm JavaScript nằm bên trong trình duyệt mới đủ thẩm quyền moi móc nó. Không hề tồn tại một thủ thuật nào — không phải là chuyện "khó", mà đích xác là không có bất kỳ cách nào — để hệ thống máy chủ chọc được vào kho dữ liệu đó.

Sự song hành của cả hai hình mẫu trong cùng một dự án

Biến Ngôn ngữ → nhét vào cookie
caches: ['cookie'],
lookupCookie: cookieLanguageKey,

Nhờ middleware đọc được thông tin, quyết định điều hướng ngôn ngữ được giải quyết dứt điểm ngay trước khi HTML thành hình.

Theme sáng/tối → nhét vào localStorage
<NextThemesProvider storageKey='theme' />

Vì máy chủ mù đặc, thư viện next-themes đành phải chèn một khối <script> chạy đồng bộ trên <head> để tút tát gắn class kịp thời trước nét vẽ đầu tiên.

Thắc mắc: Vậy vì sao Theme thì xài localStorage ngon ơ mà Ngôn ngữ lại ngậm ngùi từ bỏ?

Với Theme Sáng/TốiVới Chế độ Ngôn ngữ
Phạm vi ảnh hưởng tớiMột class CSS nhúng vào thẻ <html>Toàn bộ khối nội dung chữ nằm trong thẻ HTML
Có thể tút tát vá lỗi trước khi vẽ màn hình?Có thể — chỉ bằng một dòng script 5 dòng mãKhông thể — buộc phải đập bỏ render lại nguyên một cái cây DOM
Bộ máy Google có quan tâm?Không thèm quan tâmCực kỳ quan tâm, xem là yếu tố sống còn
Quy tắc vàng chốt hạ

Chỉ cần đặt câu hỏi: "Máy chủ có thiết yếu phải cần đến dữ kiện này trước khi phóng HTML đi không?" Nếu Có → Dùng cookie, bạn không có lối thoát nào khác. Nếu Không → Dùng localStorage, kho lưu trữ vừa thênh thang lại không gây tắc nghẽn băng thông mạng. Theme sáng/tối xét cho cùng chỉ là một thuộc tính bổ trợ; còn ngôn ngữ đóng vai trò là linh hồn nội dung. Thuộc tính có thể vá víu cấp tốc bằng script; nhưng nội dung thì tuyệt đối không.

Còn phương án chèn biến ?lang=vi thì sao?

Đây là câu hỏi thú vị và kinh điển nhất, và điều bất ngờ là câu trả lời không hề liên quan gì đến SEO như đa số mọi người lầm tưởng. Nó là hậu quả đến từ một rào cản kỹ thuật cứng ngắc bám rễ sâu trong Next.js. Hãy đọc kỹ tài liệu hướng dẫn chính thức:

Trích xuất tài liệu gốc Next.js

"Unlike Pages, Layouts (Server Components) do not receive the searchParams prop." — Dịch nôm na: vì các component Layout không được hệ thống kích hoạt render lại trong quá trình chuyển trang, do đó những thông số searchParams mà nó ôm giữ sẽ bị kẹt lại trở nên lỗi thời cũ kỹ.

Thêm vào đó, file app/[locale]/layout.tsx lại đang gồng mình bòn rút biến locale để xử lý tới tận năm tác vụ: đặt tiêu đề trang, chèn thẻ hreflang, đính thẻ canonical, định dạng thuộc tính <html lang>, và rót locale rải xuống 67 client component. Nếu dại dột dùng tham số ?lang=, Layout sẽ bị trói tay không tài nào có dữ liệu để thực thi trọn vẹn cả năm tác vụ đó.

Câu hỏi tiếp theo: Tại sao params thì được sủng ái mà searchParams lại bị ruồng bỏ? Bởi vì paramsmột phần kết cấu vĩnh cửu của đường dẫn, tức là nó mang tính định danh bản lề của một route. Layout [locale]/layout.tsx chỉ có ý nghĩa tồn tại khi nó nằm trong phân khu [locale] — chỉ cần bạn đổi locale, thì ngay lập tức chính cái Layout đó sẽ bị đập đi dựng lại từ đầu. Biến params không bao giờ có cửa trở nên cũ kỹ, vì một khi nó đổi thay thì Layout đó cũng kết liễu theo. Còn biến searchParams thì lại có thể tung tăng đổi chiều mà chẳng ảnh hưởng tới sinh mạng ai cả.

Ngoài ra, khi Google công khai hệ thống xếp hạng đối với bốn loại cấu trúc URL dành cho các trang web đa ngôn ngữ, họ đã đánh dấu đỏ báo động rằng dạng xài tham số như ?loc=de là loại "Không được khuyến nghị (Not recommended)", trong khi mô hình dùng thư mục con như example.com/de/ — chuẩn xác là cách kiến trúc dự án này đang triển khai — lại được vinh danh là cấu trúc vừa dễ dựng lại tối ưu chi phí bảo trì thấp.

Bảng đúc kết các phương án

Chỉ tiêu yêu cầuKhi chỉ dùng URLKhi chỉ dùng CookieKhi dùng localStorageTổ hợp URL + Cookie
Máy chủ vẽ chính xác ngôn ngữ thiết lập
Giữ an toàn Hydration~
Đạt chuẩn SEO đa ngôn ngữ
Giữ nguyên ngôn ngữ khi share link
Hệ thống CDN có thể đệm cache trơn tru
Khả năng nhớ lựa chọn xuyên suốt các phiên
Chính xác trong thao tác phán đoán lần đầu~
Tổng điểm hệ thống5/74/72/77/7

Phần 9

Bảy lỗi kỹ thuật đã được vạch trần và khắc phục

Những bài học dưới đây hoàn toàn không xuất hiện trong bất kỳ cuốn cẩm nang hướng dẫn i18n nào, bởi chúng được chắt lọc ra từ quá trình chúng tôi tự soi rọi lại từng dòng code của chính dự án. Đặc điểm kinh hãi chung của cả bảy lỗi này là: chúng không mảy may làm hệ thống ứng dụng gặp trục trặc (crash) bề ngoài. Chúng chỉ tàng hình và lủi thủi phơi bày ra ánh sáng khi có người rảnh rỗi đọc kỹ từng hàm code, hay khi có một người khiếm thị thực sự bật trình đọc màn hình lên.

  1. 1

    Sự cố lệch tên cookie — Toàn bộ tính năng ghi nhớ ngôn ngữ chết đứng

    Trong file proxy.ts chúng tôi cấu hình đọc cookie có nhãn NEXT_LOCALE, nhưng ở đầu kia i18next lại kiên quyết ghi lưu dữ liệu với cái tên i18next. Hai luồng ống này chưa bao giờ thực sự kết nối nhau. Khách hàng hăm hở đổi sang giao diện tiếng Nga, rồi đóng trình duyệt, khi mở lại và gõ lại tên miền trần trụi — lập tức bị ném thẳng về màn hình tiếng Anh đầy hụt hẫng.

    Con bọ (bug) này tồn tại ung dung được là bởi vì trong file settings.ts thực chất có dòng khai báo cookieName — ai nhìn lướt qua cũng chắc mẩm cấu hình ổn thoả rồi. Nhưng nó lại bị vô hiệu hóa liên tiếp tới hai tầng rào chắn: thứ nhất, option của thư viện phát hiện ngôn ngữ có cái tên chuẩn là detection.lookupCookie chứ không đời nào là cookieName; thứ hai, cái instance đang lôi option ra xài lại chính là instance hoạt động trên máy chủ, vốn không hề được khoác lên bộ công cụ phát hiện ngôn ngữ.

    Tuyệt chiêu vá lỗi: Thống nhất xài chung hằng số cookieLanguageKey ở cả hai đầu ống, và ép buộc cấu hình lookupCookie khai báo rành mạch.

  2. 2

    Hệ thống Proxy quên bẵng việc ghi cookie khi điều hướng (redirect)

    Khi khách hàng vãng lai ghé thăm trang lần đầu, proxy tỏ ra rất nhanh nhạy khi đoán ngôn ngữ từ dữ kiện header Accept-Language thế rồi… đãng trí quên luôn bước lưu lại. Những lần truy cập về sau hệ thống lại lò dò đi đoán lại từ đầu.

    Tuyệt chiêu vá lỗi: Thêm dòng response.cookies.set(...) nhúng gọn vào nhánh điều hướng — và nhắc lại, chỉ được phép để ở nhánh đó, nhằm tránh nguy cơ tàn phá cache của máy chủ CDN.

  3. 3

    Bỏ quên dòng thuộc tính <html lang>

    Lỗi không hề ném ra cảnh báo, không một dòng bôi đỏ, mọi con mắt đều mù lòa trước nó. Hậu quả là phần mềm đọc màn hình đã ê a đọc văn bản tiếng Việt bằng công cụ phát âm tiếng Anh — tạo ra thứ âm thanh the thé chói tai như tra tấn. Thêm nữa, Google cũng đành lực bất tòng tâm khi không thể xác định ngôn ngữ đích của bài viết, khiến cho mọi ưu thế SEO hào nhoáng mà kiến trúc URL mang lại bị bốc hơi.

    Căn nguyên lỗi bắt nguồn từ kết cấu hệ thống: thẻ <html> nằm chình ình ở gốc layout, ngặt nỗi layout gốc này lại lọt thỏm ra bên ngoài khu vực bao bọc của phân khu [locale], do vậy nó hoàn toàn mù mờ không tài nào moi được thông số locale.

    Tuyệt chiêu vá lỗi: Mời trọn bộ thẻ <html> cùng toàn thể binh đoàn provider di dời xuống file [locale]/layout.tsx; để lại layout gốc đóng vai trò trạm luân chuyển (pass-through) thuần túy.

  4. 4

    Thông số escapeValue lệch pha giữa client và server

    Đầu client ngoan ngoãn đặt thông số về false, nhưng đầu server đãng trí bỏ quên thế là tự nó rớt về cấu hình mặc định true. Lỗi này biến chuỗi kèm biến kiểu t('hi', { name: 'A & B' }) biến dạng thành A &amp; B ngay từ khi khối HTML xuất xưởng ở máy chủ và tiếp đó lại chuyển thành A & B sau công đoạn rèn (hydrate).

    Tuyệt chiêu vá lỗi: Kỷ luật đồng bộ cấu hình interpolation: { escapeValue: false } trên cả hai cấu hình môi trường.

  5. 5

    Lệnh 'use server' xài lộn nhãn directive

    Hãy khắc cốt ghi tâm rằng 'use server' không hề mang ý nghĩa "dòng code này được thi hành trên máy chủ". Bản chất của nó là ra chỉ thị cho mọi export trở thành các Server Action — Next.js sẽ tự động tạo ra một điểm cuối (endpoint) dạng RPC để phía client vô tư réo gọi gọi qua đường mạng. Ngặt một nỗi, hàm của chúng ta lại lù lù trả về một cục instance i18next to đùng, tức là một object mang đầy các phương thức (method) vốn dĩ chẳng thể nào đem đi serialize để gói qua mạng được.

    Tệ hơn nữa, hệ thống build vẫn vui vẻ cho qua bài kiểm tra vì Next.js ngây ngô chỉ soi xét coi các export có phải là hàm async không. Quả bom hẹn giờ này nằm phục kích chờ tới ngày đẹp trời có ai đó vô tình import nó vào một client component.

    Tuyệt chiêu vá lỗi: Lột bỏ ngay directive tai hại đó. Lời khai báo chân chính để thể hiện mong muốn "chỉ chạy độc lập ở server" phải là lệnh import 'server-only'.

  6. 6

    Định dạng số và ngày bị gõ cứng (hardcode) giá trị locale

    Trải khắp bảy mặt trận đoạn code đã lỡ tay đóng chết với mã Intl.NumberFormat('vi-VN') hoặc 'en-US' — khiến cho người dùng Nga vào coi bảng điều khiển lại ngơ ngác khi thấy con số định dạng kiểu Việt Nam. Ở một ngã ba đường khác, có hàm chêm xài lệnh toLocaleDateString() để trống không thông số, nên nó tự ý rinh nguyên giá trị locale với múi giờ của môi trường chạy: máy chủ Node vác giờ UTC, còn trình duyệt khách vác giờ UTC+7, kết quả đẻ ra hai ngày hoàn toàn lệch pha.

    Tuyệt chiêu vá lỗi: Khai báo bổ sung file utils/format/intl.ts cùng với móc (hook) useFormat(), cam kết mọi lời gọi hàm luôn được đính kèm trị số locale và timeZone rành rành.

  7. 7

    Những thiết lập cấu hình rác (thừa)

    Dòng mã backend.loadPath vô tư trỏ thẳng hướng tới public/locales — một thư mục ma vốn chưa hề tồn tại trong dự án. Option đó thực chất chỉ là "nguồn thức ăn" của duy nhất gói i18next-http-backend, trong khi dự án này thì lại trung thành xài đồ nghề resourcesToBackend. Gói thừa i18next-http-backend cắm rễ trong package.json nhưng mỏi mắt kiếm không thấy file nào đem ra import.

    Tuyệt chiêu vá lỗi: Thẳng tay xóa option và nhổ rễ gói dependency đó đi. Những cấu hình rác này tiềm ẩn hiểm họa khôn lường hơn cả những cấu hình sai sót thật sự — bởi người kỹ sư đến sau đọc vào sẽ đinh ninh rằng các file dịch được nạp qua mạng HTTP để rồi lúi húi sửa chữa bậy bạ.

Bảy bài học xương máu đúc kết được

Nguyên tắc vàngChứng cớ dẫn xuất
Các hằng số làm cầu nối giữa hai module phải được đặt hội tụ ở chung một địa điểmSự cố 1 — cookieLanguageKey
Cấu hình rác gây nhiễu loạn độc hại hơn cả lỗi cấu hình sai ngớ ngẩnSự cố 7 — backend.loadPath
Cờ chỉ dẫn Directive tuyệt đối không phải là dòng ghi chú vô hạiSự cố 5 — 'use server'
Mọi cuộc gọi API Intl phải được nhúng rành rọt tham số locale và múi giờSự cố 6
Client và server phải đồng lòng chia sẻ chung một tập thông số option cấu hìnhSự cố 4 — escapeValue
Phạm vi của i18n trải dài vượt xa khuôn khổ những dòng chữ được dịchSự cố 3 — <html lang>, bộ font subset, thẻ hreflang
Thiết lập lưới kiểm duyệt đẩy gánh nặng chặn lỗi ra tận biên (edge) của hệ thốngLớp proxy giăng lưới ngăn chặn rủi ro tuyệt đối cho 100% dòng mã nằm phía sau lưng nó
Dòng suy ngẫm ám ảnh nhất

Sự cố số 1 thuộc nhóm những con lỗi mà ở đó không hề có bất cứ một dòng code nào thực sự viết sai cấu trúc. File proxy.ts hoạt động chỉn chu khi mang ra soi riêng. File i18n.ts cũng hoạt động hoàn hảo khi chạy cô lập. Chỉ duy nhất có bản thỏa thuận ngầm giữa hai hệ thống là sai bét. Unit test sinh ra cũng chẳng thể nào bắt quả tang loại lỗi này, vì khi mang đi test cô lập từng module, tất cả đều báo trạng thái vượt qua (pass) xanh mướt. Phương án bảo vệ căn cơ nhất là lôi tất cả các hằng số liên thông ra đặt chung vào một khu vực.

Phần 10

Kho vũ khí công cụ và Chặng đường kế tiếp

Giới hạn mù mờ mà bộ não TypeScript không kham nổi

Tập tin i18next.d.ts tận dụng thành tựu của kỹ thuật declaration merging để hô biến hàng ngàn khoá key trong cấu trúc JSON thành một mớ union type — chỉ cần lỡ tay gõ sai lệnh t('nav.homee') thì ngay lập tức quy trình biên dịch sẽ quăng lỗi chặn lại. Khá là bá đạo, tuy vậy nó bị mắc chứng thiển cận vì chỉ chăm chăm soi được có một file duy nhất:

import type translation from './locales/en/translation.json';   // ← Ánh nhìn bị thu hẹp duy nhất vào tệp 'en'

Và thế là nó lòi ra ba yếu huyệt chết người:

Lỗ hổng chết ngườiNguồn cơn gây hại
Một khoá lù lù tồn tại ở nhánh en, nhưng lại không có mặt bên nhánh vi/ruCơ chế fallbackLng nhanh nhảu lấp liếm âm thầm — người dùng phía Việt Nam đột nhiên thấy lọt thỏm câu chữ tiếng Anh nhưng không một ai được báo lỗi
Những chuỗi ký tự bị gõ chết (viết cứng) trong code, chưa đi qua phễu lọc t()Tĩnh lặng như tờ, không bao giờ được đưa lên dịch thuật. Dù bị lọt lưới cũng không có báo lỗi biên dịch, không có cả lỗi chạy runtime
Mã khoá rác (thừa) — Dòng code hiển thị đã bị thẳng tay xoá sổ nhưng cái xác key thì vẫn phơi trong ruột file JSONFile phiên dịch ngày một phình to ú ụ, tốn tiền mướn người dịch hì hục cày cuốc với những thứ không bao giờ được xài đến

Bước chuyển giao từ các script viết tay nhỏ lẻ tới những công cụ chính quy uy lực

Trong rương mã nguồn (repo) hiện đang chất chứa bốn tập tin i18n_scan.json — chúng chính là thành phẩm được sinh ra từ một đoạn script quét rà tự chế, lùng sục truy lùng những chuỗi văn tự xui xẻo chưa được bọc qua phễu t():

i18n_scan.json — Dung lượng 34 KB, kết quả từ một lần kéo máy quét bằng tay
{ "file": "shared/components/auth/SocialAuthButtons.tsx",
  "line": 22, "kind": "label", "text": "Google", "vi": false }

Nó đúng là phương thuốc đặc hiệu để rịt vào cái lỗ hổng thứ hai. Dẫu vậy phương pháp này giống kiểu đánh bắt xa bờ nổ máy kéo lưới hốt một mẻ rồi thôi, nó nằm ngoài dây chuyền quy trình chạy ngầm CI, lại không tích hợp sẵn bộ sửa lỗi tự động --fix. Nó chỉ như một bức ảnh chụp kỷ niệm lưu dấu khoảnh khắc thời gian, chẳng đủ tầm để dựng thành một guồng máy quy trình hoàn chỉnh.

Thấu hiểu được nỗi đau, đội ngũ tinh hoa tạo nên i18next đã tung ra vũ khí i18next-cli — một siêu công cụ bọc lót quy tụ đầy đủ các món đồ chơi từ trích xuất (extract), sinh ép kiểu type, đồng bộ hóa dữ kiện, công cụ gọt giũa lint, cùng tích hợp đám mây cloud vươn tầm, tất cả cuộn trong một. Ba món bảo bối lợi hại khuyến nghị nên lôi ra xài ngay:

Khẩu lệnhNhiệm vụ thi hànhXử lý triệt để được cái lỗ hổng nào
statusBáo cáo tỉ lệ hoàn thiện (%) dịch thuật và vạch trần các mã khoá thiếu hụtLỗ hổng thứ nhất
syncTiến hành đồng bộ khuôn mẫu cho các ngôn ngữ phụ chiếu theo bố cục ngôn ngữ chủ lựcLỗ hổng thứ nhất và thứ ba
lintSoi mói tìm kiếm những chuỗi cứng đầu bướng bỉnh viết chết trong mã codeLỗ hổng thứ hai — y xì đúc món i18n_scan.json, nhưng siêu việt ở chỗ có thể chạy tự động bất kỳ khi nào

Và đỉnh điểm của sự sung sướng là được lôi nó gắn trực diện vào đường chuyền CI:

Cấu hình kết hợp file package.json + .gitlab-ci.yml
"i18n:check": "i18next-cli extract --ci && i18next-cli status"

# extract --ci  → Phóng ra mã thoát (exit code) ≠ 0 nếu phát giác có bất kỳ file nào thay đổi mờ ám
#                 Người nào trình PR thêm một hàm t('key.moi') mà quên bẵng việc chạy extract → Bộ CI sẽ báo còi ĐỎ đỏ rực rỡ
# status        → Phóng ra mã thoát ≠ 0 khi soi thấy còn bản dịch lủng lỗ → ra tay chặn đứng việc merge code
Cân nhắc thiệt hơn, tuyệt đối không chạy đua mù quáng theo công cụ

Khuyên bạn chân thành: nên giữ lại cái file i18next.d.ts thủ công bằng xương bằng thịt này thay vì vứt xó nó để đổi lấy câu lệnh types tự động: bởi vỏn vẹn nó chỉ có 18 dòng còm cõi, lại chẳng tốn kém một calo nào cho bước build rườm rà, và tính đồng bộ luôn ở mức cảnh giới tuyệt đỉnh do TypeScript có đặc quyền trực tiếp soi thấu lõi tệp JSON. Cài cắm thêm công cụ là để trám lỗ hổng, tuyệt nhiên không phải để đem phá bỏ cái cỗ máy đang chạy êm ru không một tiếng động.

Thước đo cuối cùng: Thêm ngôn ngữ thứ tư cần sửa bao nhiêu chỗ?

Nương theo hệ kiến trúc này, nếu bạn muốn phổ cập ứng dụng sang tiếng Nhật thì chỉ cần xắn tay áo sửa đúng bốn chỗ:

1. shared/types/language.ts        // Bơm thêm biến 'ja' vào union — TS sẽ hăm hở chỉ thẳng ra mọi cái hốc thiếu sót
2. shared/i18n/settings.ts         // Mảng appLanguages nhét thêm 'ja' — proxy ngay lập tức tự động bắt sóng
3. shared/i18n/locales/ja/         // Tạo hai bản dịch mới: translation.json + landing.json
4. components/i18n/globe/…         // Khai báo 1 entry chứa: hình ảnh cờ, toạ độ không gian, cùng thông số màu

// HOÀN TOÀN KHÔNG BAO GIỜ phải động vào: file proxy.ts, i18n.ts, i18nServer.ts,
//                  các hook useTranslation, useFormat, LocaleLink, hay toàn thể binh đoàn 67 component kia

Trên đây chính là bài kiểm định phơi bày phẩm chất sức khỏe của bộ máy i18n hữu hiệu nhất mà tôi từng trải qua. Một bài giải xuất sắc sẽ ném ra được một hằng số tối giản tí hon, dẹp tan mọi hệ lụy phụ thuộc vào kích cỡ độ phình của ứng dụng to hay nhỏ. Ngược lại, nếu hằng số ấy trồi sụt gia tăng theo quân số component, thì hỡi ôi, thiết kế kiến trúc i18n của bạn thực sự đang mục ruỗng bên trong — cho dù ngay tại khoảnh khắc hiện tại trông bề ngoài mọi chuyện có vẻ như vẫn đang trơn tru êm ấm.

Kết luận

Sự tồn tại của hai nhịp render vốn dĩ là cái giá sòng phẳng phải trả cho khát vọng vừa nhấm nháp một HTML béo ngậy đầy tràn ngôn từ cùng một trang web mượt mà đủ sức chiều chuộng mọi thao tác tay — nó chẳng phải là một khuyết tật của thiết kế phần mềm. Sứ mệnh tối thượng của một kiến trúc i18n đỉnh cao không nằm ở chỗ cắm đầu tuyệt giao né tránh hai nhịp render đó, mà ở nghệ thuật dàn xếp sao cho hai lần render ấy không có nổi một cơ hội nhỏ nhoi nào để sinh ra hai thành phẩm nghịch rẽ nhau.

Bốn hành trang cốt tủy để bạn gói gém mang về:

  1. Hãy suy tôn URL làm nguồn sự thật tối thượng — đơn giản vì nó là thành lũy duy nhất mà hệ thống máy chủ, ứng dụng trình duyệt, bầy đàn bot tìm kiếm và ngay cả những người trần mắt thịt cùng chung sức bòn rút được, đúc ra một bản dạng nguyên khối y xì đúc, bất chấp việc có hay không sự hiện thân của JavaScript.
  2. Sử dụng Cookie như một cơ quan ghi nhớ thuần khiết — chức năng của nó chỉ độc tôn được cấp quyền đọc truy vấn một lần thoáng qua trong hốc middleware, rồi ngay lập tức phải tự chuyển sinh hoàn toàn hòa làm một vào URL. Vì lẽ đó, nó không còn cái vòi bạch tuộc nào đủ độ dài để với tới gây họa hydration mismatch: vòng đời nó kết liễu trước cả thời khắc binh đoàn React được khởi động.
  3. Bản ngã của i18n hoàn toàn không chịu gò mình giới hạn vào những dòng chữ bị dịch. Việc cài cắm thuộc tính lang, tải gói subset font cho các hệ thống chữ Kirin, canh chuẩn múi giờ, rải thẻ hreflang, điều đình với độ co giãn vỡ khung layout — tuyệt đối không một hạng mục nào màng tới khái niệm "chuỗi", thế nhưng chỉ cần lỡ nhịp hỏng hóc ở một khâu thôi thì mọi bản dịch tuyệt diệu kia cũng đành biến thành vô nghĩa.
  4. Chiếc thước đo lường đẳng cấp của một hệ thống i18n được định danh bằng tổng số phí tổn vật lộn (cost) để bơm thêm ngôn ngữ thứ N — với bộ thiết kế này, tổn thất khiêm tốn chỉ dừng lại ở bốn vị trí sửa đổi, hiên ngang đứng vững bất chấp thân hình ứng dụng có phì nộn đến nhường nào.

Nội dung tư liệu được chắt chiu từ kinh nghiệm thực tiễn xây dựng mảng frontend của hệ thống WAY4 Connect — được vận hành trên Next.js 16.2, React 19, i18next 24, hỗ trợ chuỗi ba ngôn ngữ Anh · Việt · Nga. Toàn bộ các mô hình mã code cũng như thông số đo lường đều là bằng chứng sống trích từ trong ruột dự án thực.

Nguồn tư liệu tham chiếu tin cậy: Bản Cáo thị Công bố Phát hành Unicode 17.0 · Google Search Central — Tài liệu Quản trị Site Đa Vùng và Đa Ngôn Ngữ · Tài liệu Next.js — useSearchParams · Cẩm nang i18next — Trích xuất Dữ liệu Bản dịch · Dự án i18next-cli · Nghiên cứu CSA Research — Can't Read, Won't Buy (Không Thể Đọc, Thì Đừng Hòng Mua)