Dynamic Routes và generateStaticParams

Bài 12 – Cách xây dựng dynamic routes và sử dụng generateStaticParams trong Next.js

18/11/2025 DaiPhan
Bài 12 / 18

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ápMô 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:

  • generateStaticParams cho 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 generateStaticParams một cách thông minh
  • Xử lý errors và edge cases properly
  • Test với nhiều loại URL patterns
18 bài học
Bài 12
Tiến độ hoàn thành 67%

Đã hoàn thành 12/18 bài học