Dynamic Routes và generateStaticParams
Bài 12 – Cách xây dựng dynamic routes và sử dụng generateStaticParams trong Next.js
1. Giới thiệu về Dynamic Routes
Dynamic Routes cho phép bạn tạo các trang có URL tùy biến, như /products/123 hoặc /blog/react-hooks. Kết hợp với generateStaticParams, Next.js có thể build sẵn các trang này tại thời điểm build (SSG), giúp tối ưu tốc độ tải và SEO.
Trong App Router, dynamic routes sử dụng cú pháp đặc biệt để định nghĩa các tham số trong URL, cho phép bạn xây dựng các ứng dụng có cấu trúc URL linh hoạt và thân thiện với SEO.
2. Nội dung chính
2.1 Khái niệm Dynamic Routes trong App Router
Dynamic Routes trong Next.js cho phép bạn tạo các trang với URL linh động, nơi một phần của URL được thay thế bằng tham số động. Điều này đặc biệt hữu ích cho các ứng dụng như:
- E-commerce:
/products/[id],/categories/[slug] - Blog:
/blog/[slug],/posts/[id] - Documentation:
/docs/[...slug](catch-all routes) - User profiles:
/users/[username]
2.2 Cú pháp Dynamic Routes
Next.js App Router hỗ trợ nhiều loại dynamic segments:
| Cú pháp | Mô tả | Ví dụ |
|---|---|---|
[param] | Single segment | /products/[id] → /products/123 |
[...slug] | Catch-all segments | /docs/[...slug] → /docs/react/hooks |
[[...slug]] | Optional catch-all | /blog/[[...slug]] → /blog hoặc /blog/2023/jan |
2.3 Tạo trang động với page.tsx
Cách cơ bản để tạo dynamic route:
// app/products/[id]/page.tsx
interface ProductPageProps {
params: {
id: string;
};
}
export default function ProductPage({ params }: ProductPageProps) {
// params.id chứa giá trị từ URL
return <div>Product ID: {params.id}</div>;
}
2.4 generateStaticParams - Build trước các trang
generateStaticParams là một hàm đặc biệt cho phép bạn định nghĩa danh sách các giá trị params mà Next.js sẽ build tĩnh tại thời điểm build:
export async function generateStaticParams() {
// Lấy danh sách products từ API
const products = await fetch('https://api.example.com/products').then(res => res.json());
// Trả về mảng các params
return products.map((product: any) => ({
id: product.id.toString(),
}));
}
2.5 Áp dụng SSG/ISR cho Dynamic Routes
Kết hợp generateStaticParams với các tùy chọn caching để tạo SSG hoặc ISR:
export const revalidate = 3600; // Revalidate mỗi 1 giờ
export async function generateStaticParams() {
// Build tĩnh các trang phổ biến
return [{ id: '1' }, { id: '2' }, { id: '3' }];
}
3. Ví dụ thực tế
3.1 E-commerce Product Detail với [id]
// app/products/[id]/page.tsx
interface Product {
id: number;
name: string;
price: number;
description: string;
image: string;
}
interface ProductPageProps {
params: {
id: string;
};
}
// Fetch product data
async function getProduct(id: string): Promise<Product> {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 3600 } // Cache 1 giờ
});
if (!res.ok) {
throw new Error('Product not found');
}
return res.json();
}
// Generate static params cho SSG
export async function generateStaticParams() {
// Giả sử có API trả về danh sách product IDs
const res = await fetch('https://api.example.com/products/ids');
const productIds = await res.json();
// Chỉ build trước 10 sản phẩm phổ biến nhất
return productIds.slice(0, 10).map((id: number) => ({
id: id.toString(),
}));
}
// Metadata cho SEO
export async function generateMetadata({ params }: ProductPageProps) {
const product = await getProduct(params.id);
return {
title: product.name,
description: product.description,
openGraph: {
images: [product.image],
},
};
}
export default async function ProductDetail({ params }: ProductPageProps) {
const product = await getProduct(params.id);
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<img
src={product.image}
alt={product.name}
className="w-full h-96 object-cover rounded-lg"
/>
</div>
<div>
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<p className="text-2xl text-blue-600 font-semibold mb-4">
${product.price}
</p>
<p className="text-gray-600 mb-6">{product.description}</p>
<button className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
Add to Cart
</button>
</div>
</div>
</div>
);
}
3.2 Blog Post với [slug] và ISR
// app/blog/[slug]/page.tsx
interface BlogPost {
slug: string;
title: string;
content: string;
author: string;
date: string;
tags: string[];
}
interface BlogPageProps {
params: {
slug: string;
};
}
async function getBlogPost(slug: string): Promise<BlogPost> {
const res = await fetch(`https://api.example.com/blog/${slug}`, {
next: { revalidate: 1800 } // Revalidate mỗi 30 phút
});
if (!res.ok) {
notFound(); // Trigger 404 page
}
return res.json();
}
// Generate static params cho tất cả blog posts
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/blog/posts');
const posts = await res.json();
return posts.map((post: BlogPost) => ({
slug: post.slug,
}));
}
// Revalidation cho ISR
export const revalidate = 3600; // 1 giờ
export default async function BlogPost({ params }: BlogPageProps) {
const post = await getBlogPost(params.slug);
return (
<article className="max-w-4xl mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center text-gray-600 mb-4">
<span>By {post.author}</span>
<span className="mx-2">•</span>
<time>{new Date(post.date).toLocaleDateString()}</time>
</div>
<div className="flex gap-2">
{post.tags.map((tag) => (
<span key={tag} className="bg-gray-200 px-3 py-1 rounded-full text-sm">
{tag}
</span>
))}
</div>
</header>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
}
3.3 Documentation với Catch-all Routes […slug]
// app/docs/[...slug]/page.tsx
interface DocPage {
title: string;
content: string;
path: string[];
}
interface DocsPageProps {
params: {
slug: string[];
};
}
async function getDocContent(slug: string[]): Promise<DocPage> {
const path = slug.join('/');
const res = await fetch(`https://api.example.com/docs/${path}`);
if (!res.ok) {
notFound();
}
return res.json();
}
// Generate sidebar navigation
async function getDocNavigation() {
const res = await fetch('https://api.example.com/docs/navigation');
return res.json();
}
export default async function Docs({ params }: DocsPageProps) {
const [docContent, navigation] = await Promise.all([
getDocContent(params.slug),
getDocNavigation()
]);
return (
<div className="flex">
{/* Sidebar Navigation */}
<aside className="w-64 bg-gray-50 p-4">
<nav>
{navigation.map((section: any) => (
<div key={section.title} className="mb-4">
<h3 className="font-semibold mb-2">{section.title}</h3>
<ul className="space-y-1">
{section.items.map((item: any) => (
<li key={item.path}>
<a
href={`/docs/${item.path}`}
className="text-blue-600 hover:underline"
>
{item.title}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
{/* Main Content */}
<main className="flex-1 p-8">
<div className="max-w-4xl">
{/* Breadcrumb */}
<nav className="flex items-center space-x-2 text-sm text-gray-600 mb-4">
<a href="/docs" className="hover:text-blue-600">Docs</a>
{params.slug.map((segment, index) => (
<span key={index} className="flex items-center">
<span className="mx-2">/</span>
<span className={index === params.slug.length - 1 ? 'text-gray-900 font-medium' : ''}>
{segment}
</span>
</span>
))}
</nav>
<h1 className="text-3xl font-bold mb-6">{docContent.title}</h1>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: docContent.content }}
/>
</div>
</main>
</div>
);
}
4. Kiến thức trọng tâm
4.1 Khi nào nên dùng Dynamic Routes
Nên dùng khi:
- Có nhiều nội dung tương tự với cấu trúc URL nhất quán
- Cần SEO cho nhiều trang (products, blog posts, categories)
- Muốn tối ưu performance với SSG/ISR
- Có database với nhiều records
Không nên dùng khi:
- Chỉ có vài trang cố định
- Nội dung thay đổi realtime liên tục
- Không cần SEO (dashboard nội bộ)
4.2 Best Practices cho generateStaticParams
// ✅ Tốt: Giới hạn số lượng để build nhanh
export async function generateStaticParams() {
const posts = await getPopularPosts(50); // Chỉ build 50 bài phổ biến nhất
return posts.map(post => ({ slug: post.slug }));
}
// ✅ Tốt: Fallback cho các trang chưa build
export const dynamicParams = true; // Cho phép build runtime
// ❌ Tránh: Build quá nhiều trang cùng lúc
export async function generateStaticParams() {
const posts = await getAllPosts(); // Có thể hàng nghìn bài
return posts.map(post => ({ slug: post.slug })); // Build time sẽ rất lâu
}
4.3 Xử lý Error và Not Found
import { notFound } from 'next/navigation';
export default async function ProductPage({ params }: { params: { id: string } }) {
try {
const product = await getProduct(params.id);
if (!product) {
notFound(); // Hiển thị 404 page
}
return <ProductDetail product={product} />;
} catch (error) {
// Log error và hiển thị error page
console.error('Failed to fetch product:', error);
throw new Error('Failed to fetch product');
}
}
4.4 TypeScript Types cho Dynamic Routes
// app/products/[id]/page.tsx
interface PageProps {
params: {
id: string;
};
searchParams: {
[key: string]: string | string[] | undefined;
};
}
// app/docs/[...slug]/page.tsx
interface DocsPageProps {
params: {
slug: string[]; // Array vì là catch-all route
};
}
5. Bài tập nhanh
Bài 1: Blog với Categories
Tạo route /blog/category/[category]/[slug] với:
generateStaticParamscho 5 categories và 10 bài mỗi category- Breadcrumb navigation
- Related posts sidebar
// Gợi ý cấu trúc:
app/
└── blog/
└── category/
└── [category]/
└── [slug]/
└── page.tsx
Bài 2: User Profile với Sub-routes
Tạo route /users/[username]/[tab] với:
- Tabs: ‘profile’, ‘posts’, ‘settings’
- Default tab là ‘profile’
- Loading states cho mỗi tab
// Gợi ý:
export async function generateStaticParams() {
// Lấy danh sách users và tabs
const users = await getUsers();
const tabs = ['profile', 'posts', 'settings'];
return users.flatMap(user =>
tabs.map(tab => ({
username: user.username,
tab: tab
}))
);
}
Bài 3: Multi-language Documentation
Tạo route /docs/[lang]/[...slug] với:
- Language switcher
- Fallback cho ngôn ngữ không hỗ trợ
- Language-specific navigation
6. Kết luận
Dynamic Routes và generateStaticParams là nền tảng quan trọng để xây dựng các ứng dụng Next.js có cấu trúc URL linh hoạt và hiệu năng tối ưu. Khi kết hợp với SSG/ISR, bạn có thể tạo ra các trang web có:
- SEO tốt: Các trang được pre-render với nội dung đầy đủ
- Performance cao: Tận dụng CDN và caching
- Scalability: Xử lý hàng nghìn trang một cách hiệu quả
- Developer Experience: TypeScript support và clear structure
Key takeaways:
- Luôn cân nhắc giữa build time và runtime performance
- Sử dụng
generateStaticParamsmột cách thông minh - Xử lý errors và edge cases properly
- Test với nhiều loại URL patterns