i18n trong Ecommerce — Không chỉ là dịch text
Internationalization trong ecommerce sâu hơn nhiều so với thay đổi ngôn ngữ. Currency format, date locale, RTL layout, dynamic product content, và SEO per locale — mỗi thứ đều có gotcha riêng.
i18n trong Ecommerce — Không chỉ là dịch text
Nhiều team nghĩ i18n chỉ là thay string bằng key dịch. Nhưng trong ecommerce, localization bao gồm: currency format với locale rules, date display, RTL support, dynamic product content từ API, và SEO cho từng locale. Bỏ sót bất kỳ điều nào cũng sẽ gây confusion cho user.
Vấn đề 1: Currency Format với Intl.NumberFormat
ts
// lib/i18n/currency.ts
export function formatPrice(
amount: number,
currency: string,
locale: string
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
// VND không có decimal, USD có 2 decimal
minimumFractionDigits: currency === "VND" ? 0 : 2,
maximumFractionDigits: currency === "VND" ? 0 : 2,
}).format(amount);
}
// Usage:
formatPrice(150000, "VND", "vi-VN"); // → "150.000 ₫"
formatPrice(150000, "VND", "en-US"); // → "₫150,000" (locale khác, format khác)
formatPrice(29.99, "USD", "en-US"); // → "$29.99"
formatPrice(29.99, "EUR", "de-DE"); // → "29,99 €"Gotcha: Intl.NumberFormat với currency VND và locale
vi-VNdùng dấu.làm thousands separator. Vớien-US, dùng dấu,. Đừng hardcode format string.
Vấn đề 2: Dynamic Price Display theo Locale
tsx
// hooks/useLocale.ts
export function useLocale() {
const { locale } = useRouter();
const currencyMap: Record<string, { currency: string; locale: string }> = {
vi: { currency: "VND", locale: "vi-VN" },
en: { currency: "USD", locale: "en-US" },
ko: { currency: "KRW", locale: "ko-KR" },
ja: { currency: "JPY", locale: "ja-JP" },
};
const config = currencyMap[locale ?? "vi"] ?? currencyMap.vi;
return {
formatPrice: (amount: number) =>
formatPrice(amount, config.currency, config.locale),
locale: config.locale,
};
}
// Component
function ProductPrice({ priceVnd }: { priceVnd: number }) {
const { formatPrice, locale } = useLocale();
// Convert từ VND sang currency của locale hiện tại
const convertedPrice = convertCurrency(priceVnd, "VND", getLocaleCurrency(locale));
return <span className="text-xl font-bold">{formatPrice(convertedPrice)}</span>;
}Vấn đề 3: Date Format theo Locale
ts
// Không dùng format string cứng — dùng Intl.DateTimeFormat
export function formatDate(date: Date, locale: string, options?: Intl.DateTimeFormatOptions): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
...options,
}).format(date);
}
formatDate(new Date("2025-12-25"), "vi-VN"); // → "25 tháng 12, 2025"
formatDate(new Date("2025-12-25"), "en-US"); // → "December 25, 2025"
formatDate(new Date("2025-12-25"), "ko-KR"); // → "2025년 12월 25일"
// Cho thứ tự DD/MM/YYYY vs MM/DD/YYYY — Intl tự xử lý
formatDate(new Date(), "vi-VN", { dateStyle: "short" }); // → "25/12/2025"
formatDate(new Date(), "en-US", { dateStyle: "short" }); // → "12/25/2025"Vấn đề 4: RTL Layout với CSS Logical Properties
css
/* ❌ Sai: dùng directional properties */
.product-card {
margin-left: 16px;
padding-right: 12px;
border-left: 2px solid blue;
text-align: left;
}
/* ✅ Đúng: dùng logical properties — tự động flip cho RTL */
.product-card {
margin-inline-start: 16px; /* = margin-left cho LTR, margin-right cho RTL */
padding-inline-end: 12px; /* = padding-right cho LTR, padding-left cho RTL */
border-inline-start: 2px solid blue;
text-align: start; /* = left cho LTR, right cho RTL */
}tsx
// Set dir attribute dựa trên locale
export function LocaleProvider({ locale, children }: { locale: string; children: ReactNode }) {
const rtlLocales = ["ar", "he", "fa", "ur"];
const isRTL = rtlLocales.includes(locale);
return (
<html lang={locale} dir={isRTL ? "rtl" : "ltr"}>
{children}
</html>
);
}Vấn đề 5: Dynamic Content từ API
ts
// API trả về tên/mô tả sản phẩm theo locale
interface Product {
id: string;
translations: {
[locale: string]: {
name: string;
description: string;
};
};
price: number; // Luôn lưu ở đơn vị chuẩn (VND hoặc USD)
}
// Helper lấy content theo locale với fallback
export function getTranslation(
translations: Product["translations"],
locale: string,
fallbackLocale = "vi"
): { name: string; description: string } {
return translations[locale] ?? translations[fallbackLocale] ?? {
name: "N/A",
description: "",
};
}Vấn đề 6: SEO per Locale — hreflang
tsx
// app/[locale]/products/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = params;
const supportedLocales = ["vi", "en", "ko"];
return {
alternates: {
canonical: `https://shop.com/${locale}/products/${slug}`,
languages: Object.fromEntries(
supportedLocales.map((loc) => [
loc,
`https://shop.com/${loc}/products/${slug}`,
])
),
},
};
}
// Generates:
// <link rel="canonical" href="https://shop.com/vi/products/ao-thun-trang" />
// <link rel="alternate" hreflang="vi" href="https://shop.com/vi/products/ao-thun-trang" />
// <link rel="alternate" hreflang="en" href="https://shop.com/en/products/ao-thun-trang" />Key Takeaways
- Currency: dùng
Intl.NumberFormatvới locale + currency — đừng hardcode format - VND: không có decimal,
minimumFractionDigits: 0 - Dates:
Intl.DateTimeFormat— tự xử lý DD/MM vs MM/DD theo locale - RTL: dùng CSS logical properties (
margin-inline-start,padding-inline-end) - Dynamic content: API trả về translations object, fallback về ngôn ngữ mặc định
- hreflang: set trong
generateMetadatacho từng locale/page
If you found this helpful, leave a like!
Related Posts
Comments available after DB is connected.