Bài 32.3: Dynamic routes với i18n

Chào mừng các bạn quay trở lại với chuỗi bài viết về Lập trình Web Front-end! Hôm nay, chúng ta sẽ cùng nhau đi sâu vào một chủ đề vô cùng quan trọngthú vị khi xây dựng các ứng dụng web hiện đại phục vụ người dùng toàn cầu: kết hợp Dynamic Routes (Tuyến đường động) với i18n (Internationalization - Quốc tế hóa).

Bạn đã từng làm việc với các trang sản phẩm (/products/iphone-15), trang bài viết blog (/blog/cach-viet-code-sach), hoặc trang profile người dùng (/users/fullhousedev) chưa? Đó chính là những ví dụ điển hình của tuyến đường động. Thay vì tạo ra hàng trăm, hàng ngàn file tĩnh cho mỗi sản phẩm, bài viết hay người dùng, chúng ta sử dụng một mẫu tuyến đường duy nhất để xử lý tất cả.

Mặt khác, khi ứng dụng của bạn hướng đến người dùng ở nhiều quốc gia, việc hỗ trợ đa ngôn ngữ (i18n) là bắt buộc. Và điều đó thường đòi hỏi URL của bạn cũng phải phản ánh ngôn ngữ hiện tại, ví dụ: /en/products/iphone-15 cho tiếng Anh và /vi/san-pham/iphone-15 cho tiếng Việt.

Vậy, bí quyết để kết hợp hai yếu tố mạnh mẽ này lại với nhau là gì? Làm sao để tạo ra các tuyến đường vừa linh hoạt theo dữ liệu, vừa nhận biết được ngôn ngữ mà người dùng đang truy cập? Hãy cùng tìm hiểu, đặc biệt là trong ngữ cảnh của các framework hiện đại như Next.js App Router.

Dynamic Routes: Nhắc lại nhanh

Trong Next.js App Router, việc tạo tuyến đường động cực kỳ trực quan. Bạn chỉ cần sử dụng ký hiệu dấu ngoặc vuông [] trong tên thư mục.

Ví dụ: Để tạo tuyến đường cho các trang sản phẩm theo slug (một định danh duy nhất, thân thiện với SEO), bạn sẽ có cấu trúc thư mục như sau:

app/
└── products/
    └── [slug]/
        └── page.tsx

File app/products/[slug]/page.tsx lúc này sẽ xử lý mọi yêu cầu đến các URL như /products/iphone-15, /products/samsung-s24, v.v.

Bên trong component page.tsx, bạn truy cập giá trị của slug thông qua props params:

// app/products/[slug]/page.tsx

export default function ProductPage({ params }: { params: { slug: string } }) {
  const productSlug = params.slug; // Đây chính là 'iphone-15' hoặc 'samsung-s24'

  // Bây giờ bạn có thể sử dụng productSlug để fetch dữ liệu sản phẩm tương ứng
  // ... fetch data ...

  return (
    <div>
      <h1>Trang chi tiết sản phẩm: {productSlug}</h1>
      {/* Hiển thị thông tin sản phẩm */}
    </div>
  );
}

Giải thích: Component ProductPage nhận một object params từ Next.js. Object này chứa các tham số động được lấy từ URL. Trong trường hợp này, nó là slug. Chúng ta sử dụng params.slug để biết người dùng muốn xem sản phẩm nào.

i18n Routing: Đường dẫn biết nói ngôn ngữ

Next.js cung cấp các cách để xử lý i18n routing. Một phương pháp phổ biến là sử dụng tiền tố ngôn ngữ trong URL (Locale prefixing). Điều này thường được cấu hình trong next.config.js:

// next.config.js
const nextConfig = {
  i18n: {
    locales: ['en', 'fr', 'vi'], // Các ngôn ngữ được hỗ trợ
    defaultLocale: 'en', // Ngôn ngữ mặc định
  },
  // ...các cấu hình khác
};

module.exports = nextConfig;

Với cấu hình này, Next.js sẽ tự động xử lý các URL như /en/about, /fr/a-propos, /vi/gioi-thieu. Nó cũng sẽ cung cấp locale hiện tại trong component của bạn.

Tuy nhiên, trong App Router, cách tích hợp i18n thanh lịch hơn nữa là biến ngôn ngữ thành một phần động của chính cấu trúc thư mục!

Trái tim của vấn đề: Dynamic Routes WITH i18n

Đây là lúc mọi thứ trở nên mạnh mẽ. Chúng ta không chỉ muốn tuyến đường động theo slug, mà còn muốn nó động theo cả ngôn ngữ. Cấu trúc thư mục sẽ trông như thế này:

app/
└── [lang]/        <-- Thư mục động cho ngôn ngữ
    └── products/
        └── [slug]/    <-- Thư mục động cho slug sản phẩm
            └── page.tsx

File app/[lang]/products/[slug]/page.tsx giờ đây sẽ chịu trách nhiệm xử lý các URL như /en/products/iphone-15, /fr/produits/iphone-15, /vi/san-pham/iphone-15, v.v.

Bên trong component page.tsx, bạn sẽ nhận được cả hai tham số động: langslug!

// app/[lang]/products/[slug]/page.tsx

interface ProductData {
  name: string;
  description: string;
  // ... các trường khác, đã được dịch
}

// Hàm giả định lấy dữ liệu sản phẩm theo ngôn ngữ và slug
// Trong thực tế, hàm này sẽ gọi API hoặc truy vấn database của bạn
async function getProductByLangAndSlug(lang: string, slug: string): Promise<ProductData | null> {
  console.log(`Đang lấy dữ liệu sản phẩm slug '${slug}' cho ngôn ngữ '${lang}'...`);

  // Ví dụ dữ liệu giả
  const productData = {
    'awesome-product': {
      en: { name: 'Awesome Product', description: 'This is an awesome product description.' },
      fr: { name: 'Produit Génial', description: 'Ceci est une description de produit génial.' },
      vi: { name: 'Sản Phẩm Tuyệt Vời', description: 'Đây là mô tả sản phẩm tuyệt vời.' },
    },
    'another-item': {
       en: { name: 'Another Item', description: 'Description for another item.' },
       fr: { name: 'Un Autre Article', description: 'Description pour un autre article.' },
       vi: { name: 'Một Món Khác', description: 'Mô tả cho một món khác.' },
    }
    // ... dữ liệu sản phẩm khác
  };

  // Lấy dữ liệu theo slug, sau đó theo lang
  const item = (productData as any)[slug];
  if (item && item[lang]) {
      return item[lang];
  }

  return null; // Không tìm thấy sản phẩm hoặc ngôn ngữ
}


export default async function DynamicI18nProductPage({ params }: { params: { lang: string; slug: string } }) {
  const { lang, slug } = params;

  // Fetch dữ liệu sản phẩm DỰA VÀO cả lang và slug
  const product = await getProductByLangAndSlug(lang, slug);

  if (!product) {
    // Xử lý trường hợp không tìm thấy (ví dụ: hiển thị trang 404)
    return <div>Không tìm thấy sản phẩm cho ngôn ngữ {lang}</div>;
  }

  return (
    <div>
      {/* Tiêu đề H1 có thể cần được dịch ở Layout cha hoặc thông qua fetch data */}
      <h1>{product.name}</h1> {/* Tên sản phẩm đã được dịch */}
      <p>{product.description}</p> {/*  tả đã được dịch */}
      {/* ... render nội dung sản phẩm khác */}
    </div>
  );
}

Giải thích: Component DynamicI18nProductPage bây giờ nhận cả langslug từ params. Hàm getProductByLangAndSlug (mà bạn sẽ thay thế bằng logic lấy dữ liệu thật của mình) sử dụng cả hai thông số này để trả về dữ liệu sản phẩm đã được dịch. Điều này đảm bảo rằng khi người dùng truy cập /fr/produits/iphone-15, họ sẽ nhận được tên và mô tả bằng tiếng Pháp (nếu có trong dữ liệu của bạn).

generateStaticParams: Sinh tĩnh cho hiệu năng tối ưu

Nếu bạn muốn tận dụng sức mạnh của Static Site Generation (SSG) cho các trang sản phẩm động có i18n của mình (để tải trang nhanh hơn và tốt cho SEO), bạn cần cho Next.js biết tất cả các đường dẫn [lang]/products/[slug] nào cần được sinh ra trong quá trình build. Đây là lúc hàm generateStaticParams tỏa sáng.

Hàm này phải trả về một mảng các objects, mỗi object chứa tất cả các tham số động cần thiết cho một đường dẫn cụ thể. Trong trường hợp của chúng ta, mỗi object sẽ cần cả langslug.

// app/[lang]/products/[slug]/page.tsx
// ... (code component và getProductByLangAndSlug ở trên)

// Giả định bạn có một hàm lấy danh sách TẤT CẢ các slug sản phẩm khả dụng
// (có thể không cần biết ngôn ngữ ở bước này, chỉ cần ID/slug chung)
async function getAllProductSlugs(): Promise<string[]> {
  console.log("Đang lấy tất cả slug sản phẩm cho generateStaticParams...");
  // Trong thực tế: Gọi API hoặc DB để lấy danh sách slug
  return ['awesome-product', 'another-item', 'yet-another'];
}

export async function generateStaticParams() {
  const slugs = await getAllProductSlugs(); // Lấy danh sách slug: ['awesome-product', 'another-item', ...]
  const locales = ['en', 'fr', 'vi'];     // Lấy danh sách ngôn ngữ hỗ trợ

  // Kết hợp TẤT CẢ ngôn ngữ với TẤT CẢ slug
  const params = [];
  for (const lang of locales) {
    for (const slug of slugs) {
      params.push({ lang, slug });
    }
  }

  console.log("Generated static params:", params);
  /*
  Kết quả sẽ giống như:
  [
    { lang: 'en', slug: 'awesome-product' },
    { lang: 'fr', slug: 'awesome-product' },
    { lang: 'vi', slug: 'awesome-product' },
    { lang: 'en', slug: 'another-item' },
    // ... và tiếp tục cho tất cả các slug và ngôn ngữ
  ]
  */

  return params;
}

// ... (phần export default component ProductPage)

Giải thích: Hàm generateStaticParams trước hết lấy danh sách tất cả các slug sản phẩm có thể có và danh sách các locales (ngôn ngữ) mà ứng dụng hỗ trợ. Sau đó, nó tạo ra một danh sách tổ hợp của mọi lang với mọi slug. Next.js sẽ sử dụng danh sách này để build trước (pre-render) tất cả các trang chi tiết sản phẩm cho mọi ngôn ngữ. Điều này đảm bảo rằng khi người dùng lần đầu truy cập /fr/produits/awesome-product, trang đã được sinh tĩnh và tải về cực nhanh.

Xử lý các trường hợp đặc biệt: Catch-all Routes

Đôi khi, tuyến đường động của bạn cần linh hoạt hơn nữa, ví dụ như các trang danh mục sản phẩm lồng nhau (/shop/electronics/phones) hoặc các trang tài liệu có cấu trúc phức tạp. Lúc này, chúng ta dùng Catch-all Routes với ký hiệu [...].

Với i18n, cấu trúc sẽ là:

app/
└── [lang]/
    └── shop/
        └── [...slug]/ <-- Catch-all cho các phân đoạn còn lại
            └── page.tsx

File app/[lang]/shop/[...slug]/page.tsx sẽ khớp với các URL như /en/shop/electronics, /en/shop/electronics/phones, /fr/shop/vetements/homme, v.v.

Tham số slug bạn nhận được trong params lúc này sẽ là một mảng các chuỗi, đại diện cho các phân đoạn đường dẫn sau /shop/.

// app/[lang]/shop/[...slug]/page.tsx

export default function ShopCatchAllPage({ params }: { params: { lang: string; slug: string[] } }) {
  const { lang, slug } = params;

  // slug là một mảng, ví dụ: ['electronics', 'phones'] hoặc ['vetements', 'homme']
  const pathSegments = slug.join('/'); // Nối lại thành 'electronics/phones' hoặc 'vetements/homme'

  return (
    <div>
      <h1>Trang Cửa hàng Động (Catch-all)</h1>
      <p>Ngôn ngữ: {lang}</p>
      <p>Đường dẫn đã khớp: /{pathSegments}</p>
      {/* Dựa vào lang và pathSegments để hiển thị nội dung danh mục phù hợp */}
    </div>
  );
}

Giải thích: Component này nhận lang và một mảng slug. Bằng cách xử lý mảng slug, bạn có thể xác định người dùng đang muốn truy cập danh mục hoặc trang cụ thể nào trong cấu trúc phân cấp, sau đó fetch dữ liệu tương ứng với ngôn ngữ lang.

Trường hợp tùy chọn: Optional Catch-all Routes

Còn một biến thể nữa là Optional Catch-all Routes, sử dụng ký hiệu [[...slug]]. Tuyến đường này sẽ khớp với cả đường dẫn gốc của thư mục cha.

Với i18n, cấu trúc:

app/
└── [lang]/
    └── blog/
        └── [[...slug]]/ <-- Tùy chọn khớp cả /blog và /blog/post-title/...
            └── page.tsx

File app/[lang]/blog/[[...slug]]/page.tsx sẽ khớp với cả /en/blog (trang chủ blog tiếng Anh) và /en/blog/first-post, /en/blog/category/another-post, v.v.

Tham số slug trong params lúc này sẽ là một mảng các chuỗi HOẶC undefined (khi khớp với đường dẫn gốc /lang/blog).

// app/[lang]/blog/[[...slug]]/page.tsx

export default function BlogOptionalCatchAllPage({ params }: { params: { lang: string; slug?: string[] } }) {
  const { lang, slug } = params;

  const isBlogHomepage = !slug || slug.length === 0; // Kiểm tra xem có phải trang chủ blog không

  return (
    <div>
      <h1>{isBlogHomepage ? `Trang Chủ Blog (${lang.toUpperCase()})` : `Trang Bài Viết Blog (${lang.toUpperCase()})`}</h1>
      {isBlogHomepage ? (
        // Logic hiển thị danh sách bài viết cho trang chủ blog theo ngôn ngữ 'lang'
        <p>Hiển thị danh sách bài viết theo ngôn ngữ {lang}...</p>
      ) : (
        // Logic hiển thị chi tiết bài viết/trang dựa vào 'lang' và mảng 'slug'
        <p>Đường dẫn bài viết/trang đã khớp: /{slug.join('/')}</p>
      )}
      {/* ... render nội dung blog */}
    </div>
  );
}

Giải thích: Component này kiểm tra xem slug có tồn tại không. Nếu không (undefined), đó là trang chủ blog (/lang/blog). Nếu có (là một mảng), bạn xử lý mảng đó để xác định nội dung cụ thể (ví dụ: bài viết chi tiết, trang danh mục phụ) và hiển thị nó bằng ngôn ngữ lang.

Những điều cần lưu ý

Khi kết hợp Dynamic Routes và i18n, hãy nhớ:

  1. Nhất quán URL: Quyết định cấu trúc URL cho từng loại nội dung động và tuân thủ nó (/[lang]/products/[slug], /[lang]/blog/[...segments]).
  2. Lấy dữ liệu: Logic lấy dữ liệu của bạn luôn luôn phải sử dụng tham số lang để đảm bảo trả về nội dung đã được dịch chính xác.
  3. generateStaticParams là người bạn tốt: Nếu có thể, hãy sử dụng generateStaticParams để build trước các trang phổ biến, giúp cải thiện hiệu năng và SEO đáng kể. Đảm bảo hàm này trả về tất cả các tổ hợp lang và tham số động khác.
  4. Xử lý 404: Đảm bảo bạn có logic xử lý khi một tổ hợp langslug không hợp lệ được yêu cầu (ví dụ: chuyển hướng đến trang 404 tùy chỉnh theo ngôn ngữ).

Comments

There are no comments at the moment.