Blog 18ways
Cách thêm i18n và nội địa hoá đa ngôn ngữ vào một ứng dụng Next.js hiện có mà không ảnh hưởng đến SEO hay phải xây dựng lại từ đầu.
I18n (quốc tế hoá), l10n (bản địa hoá), hỗ trợ đa ngôn ngữ… gọi thế nào cũng được, cuối cùng bạn vẫn cần cập nhật ứng dụng Next.js của mình để hỗ trợ nhiều hơn một ngôn ngữ.
Khó khăn không nằm ở việc dịch một câu. Mà là thêm nhiều ngôn ngữ vào một ứng dụng Next.js thực sự mà không làm hỏng SEO, làm rối codebase, hoặc tự tạo ra một vấn đề bảo trì cho chính mình.
Nếu bạn muốn đi thẳng vào phần mã, hãy xem các ví dụ GitHub của 18ways-next.
Trước hết, chúng ta cần cài các gói của mình. Chúng ta sẽ thiết lập các thư viện 18ways, nhưng các công cụ như i18next vẫn hoạt động tốt nếu dự án của bạn đơn giản và chỉ có nội dung tĩnh.
npm install @18ways/next @18ways/reactTạo tệp cấu hình của bạn:
// 18ways.config.js
module.exports = {
apiKey: 'pk_dummy_demo_token',
baseLocale: 'en-GB',
router: 'app', // 'app', or 'path' depending on which Next.js router you are using
};Bao bọc cấu hình Next.js của bạn:
// next.config.js
const { withWays } = require('@18ways/next/config');
const nextConfig = {
/*
* your normal Next.js config here
*/
};
module.exports = withWays(nextConfig);Thêm một proxy gốc để / có thể chuyển hướng sang đúng ngôn ngữ cục bộ:
// proxy.js
export { default, config } from '@18ways/next/proxy';Sau đó thêm một layout theo ngôn ngữ cục bộ:
// app/layout.jsx
import './styles.css';
import { WaysRoot } from '@18ways/next/server';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className="next-demo-body">
<WaysRoot>{children}</WaysRoot>
</body>
</html>
);
}Và thế là xong! Giờ bạn đã có:
Giờ đây bạn có thể dịch trang đầu tiên của mình. Các công cụ như i18next sẽ yêu cầu bạn tách toàn bộ văn bản thành các khoá dịch, rồi tham chiếu chúng trong mã của bạn. Chính xác cách làm sẽ ra sao còn tuỳ vào thư viện bạn đã chọn.
Nếu bạn đang dùng 18ways, bạn chỉ cần bọc đoạn văn bản muốn dịch trong một thành phần <T>:
// src/components/MyExampleComponent.jsx
import { useT, T } from '@18ways/react';
export default function MyExampleComponent() {
const t = useT();
return (
<div>
<T>Hello world!</T>
<img
src="https://example.com/image.png"
alt={t('Example image')}
/>
</div>
);
}Có lẽ bạn sẽ muốn cho người dùng tự đổi ngôn ngữ của mình:
// src/components/Footer.jsx
import { T, LanguageSwitcher } from '@18ways/react';
export default function Footer() {
return (
<footer>
<T>My footer content</T>
<LanguageSwitcher />
</footer>
);
}Khi phần nền tảng đã vào chỗ, thường có vài cách mà những dự án này hay đi chệch hướng.
Kết xuất phía máy chủ (SSR) là thứ cho phép ứng dụng Next.js của bạn xuất HTML được dựng sẵn trên máy chủ. Điều này rất quan trọng cho cả SEO, và để người dùng không thấy những chớp lóe của nội dung lỗi hoặc ngôn ngữ sai.
Nếu bạn đang dùng một thư viện như i18next, bạn cần đặc biệt cẩn thận để đảm bảo bản dịch được tải và nạp vào trong lúc render phía máy chủ. Bạn có thể kiểm tra điều này bằng cách xem view-source: của trang, ví dụ view-source:http://localhost:3000/. Bạn cũng nên kiểm tra điều này trong production để đảm bảo nó hoạt động trong bản dựng production của bạn.
Nếu bạn đang dùng 18ways, đừng lo về chuyện này. Mọi thứ đã được xử lý cho bạn.
Nếu bạn đang dùng 18ways, bạn hoàn toàn không cần lo về điều này. 18ways không cần các khoá dịch, bạn có thể để nguyên văn bản ở vị trí như bình thường.
Nhiều hệ thống i18n yêu cầu bạn tách mã của mình thành các khoá dịch. Điều quan trọng là phải cẩn thận khi đặt tên cho những khoá này.
Các khóa tệ thì mơ hồ:
// bad-keys.en-GB.js
module.exports = {
title: 'Continue',
button: 'Pay now',
label: 'Home',
};Những khóa đó hầu như chẳng nói gì với dịch giả và lập trình viên về chỗ văn bản sẽ xuất hiện.
Các khóa tốt hơn có ngữ cảnh:
// better-keys.en-GB.js
module.exports = {
'checkout.payment.primaryButton': 'Pay now',
'checkout.payment.stepTitle': 'Complete your payment',
'account.sidebar.homeLink': 'Home',
};Cũng đừng tạo khóa một cách động:
// bad-dynamic-keys.js
const key = `checkout.${status}.${buttonType}`;
const translatedText = t(key);Điều này sẽ làm hỏng các công cụ IDE vốn cố làm cho các khoá dịch bớt khó nuốt hơn. Nó cũng sẽ khiến việc tìm kiếm và dọn dẹp các khoá dịch cũ trở nên cực kỳ khó khăn.
// better-dynamic-keys.js
const keyMap = {
success: {
primary: t('checkout.success.primary'),
default: t('checkout.success.default'),
},
error: {
primary: t('checkout.error.primary'),
default: t('checkout.error.default'),
},
};
const translatedText = keyMap[status][buttonType];Cách này ít DRY hơn, nhưng là cách tốt nhất để ngăn các khóa dịch trở nên khó quản lý.
Còn tốt hơn nữa là dùng một công cụ như 18ways, khi đó bạn chẳng bao giờ cần đến khoá dịch cả:
// app/[lang]/checkout/page.jsx
<T>Pay now</T>Phát hiện locale nghĩa là xác định người dùng nên thấy ngôn ngữ nào trước khi họ tự chuyển đổi.
Thường sẽ liên quan đến một số kết hợp của:
Accept-Language của trình duyệtTrong middleware Next.js thuần, điều đó thường trông như thế này:
// middleware.js
/**
* You don't need any of this if you're using 18ways
*/
import { NextResponse } from 'next/server';
const acceptedLocales = ['en-GB', 'fr-FR'];
export function middleware(request) {
const savedLocale =
request.cookies.get('preferred-locale')?.value;
const browserLocale =
request.headers
.get('accept-language')
?.split(',')[0] || 'en-GB';
const locale = acceptedLocales.includes(savedLocale)
? savedLocale
: acceptedLocales.includes(browserLocale)
? browserLocale
: 'en-GB';
if (request.nextUrl.pathname === '/') {
return NextResponse.redirect(
new URL(`/${locale}`, request.url)
);
}
return NextResponse.next();
}Một số thư viện có các hàm hỗ trợ để việc này dễ hơn, ở những mức độ khác nhau. Với 18ways, phần phát hiện ban đầu và lớp chuyển hướng đã được xử lý cho bạn.
Các ngôn ngữ khác nhau định dạng ngày tháng, số và tiền tệ khác nhau.
Ví dụ:
04/05/2026 có thể có nghĩa là ngày 4 tháng 5 hoặc ngày 5 tháng 4€1,999.00 và 1.999,00 € đều hợp lệ, tuỳ theo ngôn ngữ cục bộTrong JavaScript thuần, bạn tự xử lý việc đó bằng Intl:
// formatting-dates-and-currency.js
const myLocale = getCurrentLocale(); // depends on your lib
const someTimestamp = new Date('2026-04-13T09:00:00Z');
const dateLabel = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'long',
}).format(someTimestamp);
const someMoney = {
amount: 1999,
currency: 'EUR',
};
const moneyLabel = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: someMoney.currency,
}).format(someMoney.amount);
const translatedText = t(
'my.translation.key',
{ dateLabel, moneyLabel }
);Nếu bạn đang dùng 18ways, việc này đã được xử lý sẵn cho bạn:
const someTimestamp = new Date('2026-04-13T09:00:00Z');
const someMoney = {
amount: 1999,
currency: 'EUR',
};
<T>My text with {{ someTimestamp }} and {{ someMoney }}</T>Đừng xây dựng giao diện đã dịch bằng cách ghép các mảnh rời lại với nhau:
// bad-string-joining.jsx
const clickHereText = t('click.here');
const toGetStartedText = t('to.get.started')
<p><a href="#">{clickHereText}</a> {toGetStartedText}.</p>Điều này sẽ hỏng ở nhiều ngôn ngữ.
Trong tiếng Anh, bạn có thể nói “👉👉Nhấn vào đây👈👈 để bắt đầu”, nhưng trong tiếng Pháp, cách tự nhiên hơn là nói “Pour commencer, 👉👉cliquez ici👈👈”. Một số ngôn ngữ như tiếng Nhật còn cần từ ngữ cả trước lẫn sau, như “始めるには👉👉こちら👈👈をクリックしてください”.
Cấu trúc câu có thể thay đổi, nên tách nó thành từng phần sẽ khiến việc dịch chính xác khó hơn nhiều.
Dịch cả câu sẽ tốt hơn vì biên dịch viên có thể sắp xếp lại từ ngữ một cách tự nhiên và thấy nghĩa như một đơn vị hoàn chỉnh.
Nếu bạn đang dịch JSX như thế này:
// rich-text-message.jsx
<p>
Something <strong>that we want to be bold</strong>
</p>bạn sẽ phải tham khảo thư viện i18n của mình để biết cách xử lý việc này, vì mỗi thư viện lại xử lý rất khác nhau.
Nếu bạn đang dùng 18ways, bạn có thể chỉ cần dịch cả khối JSX như bình thường.
// rich-text-message.jsx
<p>
<T>Something <strong>that we want to be bold</strong></T>
</p>Biến cho phép bạn giữ nguyên cả câu mà vẫn chèn được giá trị lúc chạy.
Phần lớn thư viện i18n đều hỗ trợ điều này dưới một hình thức nào đó. Với 18ways:
// app/[lang]/page.jsx
<T>Hello {{ name: 'Ada' }}</T>Mẫu đó hoạt động tốt cho:
Câu vẫn dễ đọc, còn giá trị thì vẫn rõ ràng.
Nếu bạn cần chính biến đó cũng được dịch, hãy nhớ bọc nó trong t(...):
const animal = t('dog');
<T>Favourite animal: {{ animal }}</T>Quy tắc số nhiều khác nhau theo từng ngôn ngữ. Tiếng Anh có một dạng số nhiều (1 year, 2 years, v.v.). Tiếng Ba Lan có nhiều dạng số nhiều (1 rok, 2 lata, 5 lat). Tiếng Nhật thì không có dạng số nhiều nào cả (1 年, 2 年, 3 年).
Phần lớn thư viện i18n cho phép bạn chỉ định cú pháp kiểu ICU để xử lý số nhiều:
// app/[lang]/page.jsx
<T>
{{
unreadCount,
format:
'plural, =0{No unread messages} =1{One unread message} other{{unreadCount} unread messages}',
}}
</T>Phần trên sẽ hoạt động trong 18ways, nhưng trong hầu hết trường hợp, bạn cũng chỉ cần làm thế này:
// app/[lang]/page.jsx
<T>{{ unreadCount }} unread messages</T>18ways sẽ xử lý số nhiều giúp bạn!
Việc thêm nhiều ngôn ngữ vào một ứng dụng Next.js không cần biến thành một dự án viết lại từ đầu.
Nếu bạn không dùng một giải pháp như 18ways — hãy đảm bảo định tuyến và SSR của bạn hoạt động đúng, rồi bắt tay vào chuyển nội dung của mình sang các khoá dịch, đồng thời tránh những cạm bẫy i18n thường gặp.
Nếu bạn đang dùng 18ways, thì mọi thứ này đã được xử lý cho bạn, và bạn chỉ cần bắt đầu bọc văn bản của mình trong các khối <T>!