Back to Notes
Architecture1 phút đọcNovember 19, 2025

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-VN dùng dấu . làm thousands separator. Với en-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.NumberFormat vớ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 generateMetadata cho từng locale/page

If you found this helpful, leave a like!

Related Posts

Comments available after DB is connected.