Back to blog

Build a Personal Blog — Phase 4: Tags, Search & Pagination

nextjsreacttypescriptblogshadcn
Build a Personal Blog — Phase 4: Tags, Search & Pagination

Đây là Phase 4 của series Build a Personal Blog. Blog của bạn giờ đã render được MDX content và lưu dynamic data trong PostgreSQL. Nhưng người đọc vẫn chưa thể tìm được gì — không có cách nào để filter theo chủ đề, search từ khoá, hay duyệt từng trang. Giờ là lúc khắc phục.

Series: Build a Personal Blog — Complete Roadmap
Trước: Phase 3 — PostgreSQL + Drizzle ORM
Tiếp: Phase 5 — Docker Compose
Source Code: GitHub — personal-blog-phase-4


Bạn Sẽ Build Được Gì

Sau phase này:

✅ Hệ thống tag trích xuất tags từ frontmatter và link đến tag index page
✅ Tag index page tại /blog/tags/[tag] hiển thị bài viết đã filter
✅ Search bar sử dụng URL search params (server-side, tương thích RSC)
✅ Pagination với prev/next controls và page numbers
✅ Reading time ước lượng thời gian đọc cho mỗi bài
✅ Featured/pinned posts qua frontmatter flag
✅ SEO metadata cho tag pages và paginated listings

Time commitment: 3–5 giờ
Prerequisites: Phase 3 — PostgreSQL + Drizzle ORM


1. Mở Rộng Post Type

Đầu tiên, update post types để hỗ trợ các feature mới. Mở lib/posts.ts và mở rộng frontmatter interface:

// lib/posts.ts
export interface PostFrontmatter {
  title: string;
  description: string;
  short_description?: string;
  date: string;
  tags: string[];
  image?: string;
  cover_image?: string;
  featured?: boolean;  // MỚI — ghim bài viết lên đầu
}
 
export interface Post {
  slug: string;
  frontmatter: PostFrontmatter;
  content: string;
  readingTime: number;  // MỚI — ước lượng số phút đọc
}

2. Tính Thời Gian Đọc

Reading time là một chi tiết nhỏ nhưng khiến blog trông chuyên nghiệp hơn hẳn. Phép tính đơn giản: đếm số từ, chia cho tốc độ đọc trung bình (~200 từ/phút cho nội dung technical).

Thêm helper này vào lib/posts.ts:

// lib/posts.ts
 
/**
 * Ước lượng reading time (phút).
 * Bỏ code blocks và frontmatter, đếm số từ còn lại.
 * Dùng 200 wpm cho technical content (chậm hơn đọc thông thường).
 */
function calculateReadingTime(content: string): number {
  // Bỏ code blocks (người đọc scan chứ không đọc từng từ)
  const withoutCode = content.replace(/```[\s\S]*?```/g, "");
  // Bỏ frontmatter
  const withoutFrontmatter = withoutCode.replace(/^---[\s\S]*?---/, "");
  // Đếm từ
  const words = withoutFrontmatter
    .trim()
    .split(/\s+/)
    .filter((word) => word.length > 0).length;
 
  return Math.max(1, Math.ceil(words / 200));
}

Giờ update getAllPosts để bao gồm reading time:

// lib/posts.ts
 
export async function getAllPosts(): Promise<Post[]> {
  const postsDir = path.join(process.cwd(), "content/posts");
  const files = fs.readdirSync(postsDir).filter((f) => f.endsWith(".mdx"));
 
  const posts: Post[] = files.map((filename) => {
    const slug = filename.replace(/\.mdx$/, "");
    const filePath = path.join(postsDir, filename);
    const fileContent = fs.readFileSync(filePath, "utf-8");
    const { data, content } = matter(fileContent);
 
    return {
      slug,
      frontmatter: data as PostFrontmatter,
      content,
      readingTime: calculateReadingTime(fileContent),
    };
  });
 
  // Sắp xếp: bài featured lên đầu, rồi theo ngày giảm dần
  return posts.sort((a, b) => {
    if (a.frontmatter.featured && !b.frontmatter.featured) return -1;
    if (!a.frontmatter.featured && b.frontmatter.featured) return 1;
    return new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime();
  });
}

Tương tự, update getPostBySlug để trả về reading time:

// lib/posts.ts
 
export async function getPostBySlug(
  slug: string
): Promise<Post | null> {
  const filePath = path.join(
    process.cwd(),
    "content/posts",
    `${slug}.mdx`
  );
 
  if (!fs.existsSync(filePath)) return null;
 
  const fileContent = fs.readFileSync(filePath, "utf-8");
  const { data, content } = matter(fileContent);
 
  return {
    slug,
    frontmatter: data as PostFrontmatter,
    content,
    readingTime: calculateReadingTime(fileContent),
  };
}

Hiển thị reading time

Thêm nó cạnh ngày tháng trên post page và post cards:

// components/PostCard.tsx (một phần)
<div className="flex items-center gap-2 text-sm text-muted-foreground">
  <time dateTime={post.frontmatter.date}>
    {formatDate(post.frontmatter.date)}
  </time>
  <span>·</span>
  <span>{post.readingTime} min read</span>
</div>

3. Hệ Thống Tag

3.1 Trích Xuất Tất Cả Tags

Thêm helper thu thập mọi tag unique từ tất cả bài viết và đếm số bài dùng mỗi tag:

// lib/posts.ts
 
export interface TagInfo {
  name: string;
  count: number;
}
 
/**
 * Lấy tất cả tags unique kèm post count, sắp xếp theo count giảm dần.
 */
export async function getAllTags(): Promise<TagInfo[]> {
  const posts = await getAllPosts();
  const tagMap = new Map<string, number>();
 
  for (const post of posts) {
    for (const tag of post.frontmatter.tags) {
      const normalized = tag.toLowerCase();
      tagMap.set(normalized, (tagMap.get(normalized) ?? 0) + 1);
    }
  }
 
  return Array.from(tagMap.entries())
    .map(([name, count]) => ({ name, count }))
    .sort((a, b) => b.count - a.count);
}
 
/**
 * Lấy tất cả bài viết có tag cụ thể.
 */
export async function getPostsByTag(tag: string): Promise<Post[]> {
  const posts = await getAllPosts();
  return posts.filter((post) =>
    post.frontmatter.tags
      .map((t) => t.toLowerCase())
      .includes(tag.toLowerCase())
  );
}

3.2 Tag Badge Component

Tạo tag badge tái sử dụng bằng component Badge của ShadCN/UI:

// components/TagBadge.tsx
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
 
interface TagBadgeProps {
  tag: string;
  count?: number;
  clickable?: boolean;
}
 
export function TagBadge({ tag, count, clickable = true }: TagBadgeProps) {
  const content = (
    <Badge variant="secondary" className="cursor-pointer hover:bg-accent">
      {tag}
      {count !== undefined && (
        <span className="ml-1 text-xs text-muted-foreground">({count})</span>
      )}
    </Badge>
  );
 
  if (!clickable) return content;
 
  return (
    <Link href={`/blog/tags/${encodeURIComponent(tag)}`}>
      {content}
    </Link>
  );
}

3.3 Hiển Thị Tags Trên Post Cards

Thêm tags vào component PostCard:

// components/PostCard.tsx (một phần — thêm bên dưới date/reading time)
<div className="mt-2 flex flex-wrap gap-1.5">
  {post.frontmatter.tags.slice(0, 4).map((tag) => (
    <TagBadge key={tag} tag={tag} clickable={true} />
  ))}
</div>

Chỉ hiển thị tối đa 4 tags trên card để layout gọn gàng. Danh sách đầy đủ sẽ hiển thị ở trang chi tiết bài viết.

3.4 Tag Index Page

Tạo dynamic route cho /blog/tags/[tag]:

// app/blog/tags/[tag]/page.tsx
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPostsByTag, getAllTags } from "@/lib/posts";
import { PostCard } from "@/components/PostCard";
import { TagBadge } from "@/components/TagBadge";
 
interface TagPageProps {
  params: Promise<{ tag: string }>;
}
 
export async function generateMetadata({
  params,
}: TagPageProps): Promise<Metadata> {
  const { tag } = await params;
  const decodedTag = decodeURIComponent(tag);
 
  return {
    title: `Posts tagged "${decodedTag}"`,
    description: `All blog posts about ${decodedTag}. Tutorials, guides, and best practices.`,
  };
}
 
export async function generateStaticParams() {
  const tags = await getAllTags();
  return tags.map((tag) => ({ tag: encodeURIComponent(tag.name) }));
}
 
export default async function TagPage({ params }: TagPageProps) {
  const { tag } = await params;
  const decodedTag = decodeURIComponent(tag);
  const posts = await getPostsByTag(decodedTag);
 
  if (posts.length === 0) {
    notFound();
  }
 
  const allTags = await getAllTags();
 
  return (
    <div className="mx-auto max-w-4xl px-4 py-12">
      <h1 className="text-3xl font-bold">
        Posts tagged &ldquo;{decodedTag}&rdquo;
      </h1>
      <p className="mt-2 text-muted-foreground">
        {posts.length} {posts.length === 1 ? "post" : "posts"} found
      </p>
 
      {/* Tag cloud — hiển thị tất cả tags để khám phá */}
      <div className="mt-6 flex flex-wrap gap-2">
        {allTags.map((t) => (
          <TagBadge
            key={t.name}
            tag={t.name}
            count={t.count}
            clickable={t.name !== decodedTag}
          />
        ))}
      </div>
 
      {/* Bài viết đã filter */}
      <div className="mt-8 space-y-6">
        {posts.map((post) => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
    </div>
  );
}

Có hai cách thêm search vào blog Next.js:

ApproachChạy ở đâuURL shareableSEOComplexity
Client-side (useState)Browser❌ Không❌ Không indexThấp
Server-side (URL search params)Server✅ Có✅ Được indexThấp

Mình sẽ dùng server-side search với URL search params — dễ build như nhau, mà URL có thể chia sẻ được (/blog?q=docker). Vì posts là MDX files trên disk, "search engine" thực chất chỉ là string matching trên title, description, và tags.

4.1 Search Function

Thêm search helper vào lib/posts.ts:

// lib/posts.ts
 
/**
 * Search posts theo query string.
 * Match với title, description, và tags.
 * Case-insensitive, trả về kết quả sắp xếp theo relevance.
 */
export async function searchPosts(query: string): Promise<Post[]> {
  const posts = await getAllPosts();
  const q = query.toLowerCase().trim();
 
  if (!q) return posts;
 
  return posts.filter((post) => {
    const { title, description, tags } = post.frontmatter;
    const searchable = [
      title,
      description,
      ...tags,
    ]
      .join(" ")
      .toLowerCase();
 
    return q.split(/\s+/).every((word) => searchable.includes(word));
  });
}

Search tách query thành các từ và yêu cầu tất cả đều match (logic AND). Nên search "nextjs docker" sẽ tìm bài viết có cả "nextjs" lẫn "docker" trong title, description, hoặc tags.

4.2 SearchBar Component

Tạo client component để update URL search params:

// components/SearchBar.tsx
"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input";
import { Search, X } from "lucide-react";
 
export function SearchBar() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();
 
  const initialQuery = searchParams.get("q") ?? "";
  const [query, setQuery] = useState(initialQuery);
 
  function handleSearch(value: string) {
    setQuery(value);
 
    startTransition(() => {
      const params = new URLSearchParams(searchParams.toString());
      if (value) {
        params.set("q", value);
        params.delete("page"); // reset về page 1 khi search mới
      } else {
        params.delete("q");
      }
      router.push(`/blog?${params.toString()}`);
    });
  }
 
  function handleClear() {
    handleSearch("");
  }
 
  return (
    <div className="relative">
      <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
      <Input
        type="text"
        placeholder="Search posts..."
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        className="pl-9 pr-9"
      />
      {query && (
        <button
          type="button"
          onClick={handleClear}
          className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
        >
          <X className="h-4 w-4" />
        </button>
      )}
      {isPending && (
        <div className="absolute right-10 top-1/2 -translate-y-1/2">
          <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
        </div>
      )}
    </div>
  );
}

Những quyết định design quan trọng:

  • URL-driven: Search query nằm trong URL (?q=docker), không phải React state. Điều này giúp search results có thể share và bookmark được.
  • useTransition: Giữ input responsive trong khi page re-render ở server-side. User thấy spinner thay vì input bị đơ.
  • Reset pagination: Khi search query thay đổi, xoá param page để kết quả bắt đầu từ trang 1.
  • Clear button: Icon X xoá search và hiển thị lại tất cả bài viết.

4.3 Kết Nối Search Vào Blog Page

Update blog listing page để đọc q search param:

// app/blog/page.tsx
import { searchPosts, getAllTags } from "@/lib/posts";
import { PostCard } from "@/components/PostCard";
import { SearchBar } from "@/components/SearchBar";
import { TagBadge } from "@/components/TagBadge";
import { Suspense } from "react";
 
interface BlogPageProps {
  searchParams: Promise<{ q?: string; page?: string }>;
}
 
export default async function BlogPage({ searchParams }: BlogPageProps) {
  const { q, page } = await searchParams;
  const posts = await searchPosts(q ?? "");
  const allTags = await getAllTags();
 
  return (
    <div className="mx-auto max-w-4xl px-4 py-12">
      <h1 className="text-3xl font-bold">Blog</h1>
 
      {/* Search bar */}
      <div className="mt-6">
        <Suspense fallback={null}>
          <SearchBar />
        </Suspense>
      </div>
 
      {/* Tag cloud */}
      <div className="mt-4 flex flex-wrap gap-2">
        {allTags.map((tag) => (
          <TagBadge key={tag.name} tag={tag.name} count={tag.count} />
        ))}
      </div>
 
      {/* Thông tin kết quả search */}
      {q && (
        <p className="mt-4 text-sm text-muted-foreground">
          {posts.length} {posts.length === 1 ? "result" : "results"} cho &ldquo;{q}&rdquo;
        </p>
      )}
 
      {/* Danh sách bài viết */}
      <div className="mt-6 space-y-6">
        {posts.length > 0 ? (
          posts.map((post) => (
            <PostCard key={post.slug} post={post} />
          ))
        ) : (
          <p className="text-center text-muted-foreground py-12">
            Không tìm thấy bài viết nào. Thử từ khoá khác nhé.
          </p>
        )}
      </div>
    </div>
  );
}

Tại sao lại Suspense bọc SearchBar? Hook useSearchParams() yêu cầu Suspense boundary khi dùng trong Server Component page. Thiếu nó, Next.js sẽ báo lỗi build.


5. Pagination

Khi bạn có hơn cả chục bài viết, hiển thị tất cả trên một trang sẽ tạo ra một bức tường content. Pagination chia danh sách thành các trang vừa phải.

5.1 Logic Pagination

Thêm pagination helpers vào lib/posts.ts:

// lib/posts.ts
 
export const POSTS_PER_PAGE = 6;
 
export interface PaginatedResult {
  posts: Post[];
  totalPosts: number;
  totalPages: number;
  currentPage: number;
  hasNextPage: boolean;
  hasPrevPage: boolean;
}
 
/**
 * Phân trang một array posts.
 */
export function paginatePosts(
  posts: Post[],
  page: number = 1,
  perPage: number = POSTS_PER_PAGE
): PaginatedResult {
  const totalPosts = posts.length;
  const totalPages = Math.max(1, Math.ceil(totalPosts / perPage));
  const currentPage = Math.min(Math.max(1, page), totalPages);
  const start = (currentPage - 1) * perPage;
  const end = start + perPage;
 
  return {
    posts: posts.slice(start, end),
    totalPosts,
    totalPages,
    currentPage,
    hasNextPage: currentPage < totalPages,
    hasPrevPage: currentPage > 1,
  };
}

5.2 Pagination Component

Build pagination UI với ShadCN/UI:

// components/Pagination.tsx
import Link from "next/link";
import {
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
 
interface PaginationProps {
  currentPage: number;
  totalPages: number;
  basePath: string;
  searchParams?: Record<string, string>;
}
 
export function Pagination({
  currentPage,
  totalPages,
  basePath,
  searchParams = {},
}: PaginationProps) {
  if (totalPages <= 1) return null;
 
  function buildUrl(page: number): string {
    const params = new URLSearchParams(searchParams);
    if (page > 1) {
      params.set("page", String(page));
    } else {
      params.delete("page");
    }
    const qs = params.toString();
    return qs ? `${basePath}?${qs}` : basePath;
  }
 
  // Hiển thị tối đa 5 số trang, centered quanh trang hiện tại
  const pageNumbers: number[] = [];
  const start = Math.max(1, currentPage - 2);
  const end = Math.min(totalPages, start + 4);
  for (let i = start; i <= end; i++) {
    pageNumbers.push(i);
  }
 
  return (
    <nav
      aria-label="Pagination"
      className="mt-12 flex items-center justify-center gap-1"
    >
      {/* Trang đầu */}
      <Button
        variant="ghost"
        size="icon"
        asChild
        disabled={currentPage <= 1}
      >
        <Link href={buildUrl(1)} aria-label="First page">
          <ChevronsLeft className="h-4 w-4" />
        </Link>
      </Button>
 
      {/* Trang trước */}
      <Button
        variant="ghost"
        size="icon"
        asChild
        disabled={currentPage <= 1}
      >
        <Link
          href={buildUrl(currentPage - 1)}
          aria-label="Previous page"
        >
          <ChevronLeft className="h-4 w-4" />
        </Link>
      </Button>
 
      {/* Số trang */}
      {pageNumbers.map((page) => (
        <Button
          key={page}
          variant={page === currentPage ? "default" : "ghost"}
          size="icon"
          asChild={page !== currentPage}
        >
          {page === currentPage ? (
            <span>{page}</span>
          ) : (
            <Link href={buildUrl(page)}>{page}</Link>
          )}
        </Button>
      ))}
 
      {/* Trang sau */}
      <Button
        variant="ghost"
        size="icon"
        asChild
        disabled={currentPage >= totalPages}
      >
        <Link
          href={buildUrl(currentPage + 1)}
          aria-label="Next page"
        >
          <ChevronRight className="h-4 w-4" />
        </Link>
      </Button>
 
      {/* Trang cuối */}
      <Button
        variant="ghost"
        size="icon"
        asChild
        disabled={currentPage >= totalPages}
      >
        <Link href={buildUrl(totalPages)} aria-label="Last page">
          <ChevronsRight className="h-4 w-4" />
        </Link>
      </Button>
    </nav>
  );
}

5.3 Update Blog Page Với Pagination

Tích hợp pagination vào blog listing:

// app/blog/page.tsx (updated)
import { searchPosts, getAllTags, paginatePosts } from "@/lib/posts";
import { PostCard } from "@/components/PostCard";
import { SearchBar } from "@/components/SearchBar";
import { TagBadge } from "@/components/TagBadge";
import { Pagination } from "@/components/Pagination";
import { Suspense } from "react";
 
interface BlogPageProps {
  searchParams: Promise<{ q?: string; page?: string }>;
}
 
export default async function BlogPage({ searchParams }: BlogPageProps) {
  const { q, page } = await searchParams;
  const allPosts = await searchPosts(q ?? "");
  const allTags = await getAllTags();
 
  const currentPage = parseInt(page ?? "1", 10);
  const paginated = paginatePosts(allPosts, currentPage);
 
  // Build search params object cho pagination URLs
  const paginationParams: Record<string, string> = {};
  if (q) paginationParams.q = q;
 
  return (
    <div className="mx-auto max-w-4xl px-4 py-12">
      <h1 className="text-3xl font-bold">Blog</h1>
 
      {/* Search bar */}
      <div className="mt-6">
        <Suspense fallback={null}>
          <SearchBar />
        </Suspense>
      </div>
 
      {/* Tag cloud */}
      <div className="mt-4 flex flex-wrap gap-2">
        {allTags.map((tag) => (
          <TagBadge key={tag.name} tag={tag.name} count={tag.count} />
        ))}
      </div>
 
      {/* Thông tin kết quả search */}
      {q && (
        <p className="mt-4 text-sm text-muted-foreground">
          {paginated.totalPosts} {paginated.totalPosts === 1 ? "result" : "results"} cho &ldquo;{q}&rdquo;
        </p>
      )}
 
      {/* Danh sách bài viết */}
      <div className="mt-6 space-y-6">
        {paginated.posts.length > 0 ? (
          paginated.posts.map((post) => (
            <PostCard key={post.slug} post={post} />
          ))
        ) : (
          <p className="text-center text-muted-foreground py-12">
            Không tìm thấy bài viết nào. Thử từ khoá khác nhé.
          </p>
        )}
      </div>
 
      {/* Pagination controls */}
      <Pagination
        currentPage={paginated.currentPage}
        totalPages={paginated.totalPages}
        basePath="/blog"
        searchParams={paginationParams}
      />
    </div>
  );
}

Giờ URL của blog kể cả câu chuyện: /blog?q=docker&page=2 nghĩa là "trang 2 của kết quả search cho docker". Ai cũng có thể bookmark, share, hoặc link đến URL này.


Đôi khi bạn muốn một bài viết luôn ở đầu danh sách bất kể ngày tháng — bài giới thiệu, tutorial nổi bật, hay overview của series.

6.1 Frontmatter Flag

Thêm featured: true vào frontmatter của bất kỳ bài viết nào:

---
title: "Build a Personal Blog — Roadmap"
description: "Complete guide to building and deploying your own blog"
date: "2026-02-20"
tags: ["nextjs", "blog"]
featured: true
---

6.2 Logic Sắp Xếp

Function getAllPosts() đã xử lý rồi — bài featured sắp xếp lên trước, sau đó theo ngày:

// Đã có trong getAllPosts() đã update
return posts.sort((a, b) => {
  if (a.frontmatter.featured && !b.frontmatter.featured) return -1;
  if (!a.frontmatter.featured && b.frontmatter.featured) return 1;
  return new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime();
});

6.3 Indicator Trực Quan

Hiển thị icon pin nhỏ trên bài featured trong PostCard:

// components/PostCard.tsx (một phần — thêm vào card header)
import { Pin } from "lucide-react";
 
// Bên trong component:
{post.frontmatter.featured && (
  <span className="inline-flex items-center gap-1 text-xs font-medium text-primary">
    <Pin className="h-3 w-3" />
    Featured
  </span>
)}

7. SEO Cho Tag Pages

Mỗi tag page cần có metadata đầy đủ cho search engines. Mình đã thêm generateMetadata trong tag page, nhưng hãy làm nó hoàn chỉnh hơn:

// app/blog/tags/[tag]/page.tsx (updated generateMetadata)
export async function generateMetadata({
  params,
}: TagPageProps): Promise<Metadata> {
  const { tag } = await params;
  const decodedTag = decodeURIComponent(tag);
  const posts = await getPostsByTag(decodedTag);
 
  return {
    title: `Posts tagged "${decodedTag}" | My Blog`,
    description: `Browse ${posts.length} ${
      posts.length === 1 ? "article" : "articles"
    } about ${decodedTag}. Tutorials, guides, and practical examples.`,
    openGraph: {
      title: `Posts tagged "${decodedTag}"`,
      description: `${posts.length} articles about ${decodedTag}`,
      type: "website",
    },
  };
}

Canonical URLs cho Paginated Pages

Cho blog page chính, thêm dynamic metadata xử lý pagination:

// app/blog/page.tsx (thêm ở đầu file)
import { Metadata } from "next";
 
interface BlogPageProps {
  searchParams: Promise<{ q?: string; page?: string }>;
}
 
export async function generateMetadata({
  searchParams,
}: BlogPageProps): Promise<Metadata> {
  const { q, page } = await searchParams;
  const pageNum = parseInt(page ?? "1", 10);
 
  let title = "Blog";
  if (q) title = `Search: "${q}" | Blog`;
  if (pageNum > 1) title += ` — Page ${pageNum}`;
 
  return {
    title,
    description: q
      ? `Search results for "${q}"`
      : "Read the latest articles about web development, Next.js, TypeScript, and more.",
  };
}

8. Thêm Pagination Cho Tag Pages

Tag pages có nhiều bài cũng cần pagination. Cách làm giống hệt:

// app/blog/tags/[tag]/page.tsx (updated)
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPostsByTag, getAllTags, paginatePosts } from "@/lib/posts";
import { PostCard } from "@/components/PostCard";
import { TagBadge } from "@/components/TagBadge";
import { Pagination } from "@/components/Pagination";
 
interface TagPageProps {
  params: Promise<{ tag: string }>;
  searchParams: Promise<{ page?: string }>;
}
 
export default async function TagPage({ params, searchParams }: TagPageProps) {
  const { tag } = await params;
  const { page } = await searchParams;
  const decodedTag = decodeURIComponent(tag);
  const posts = await getPostsByTag(decodedTag);
 
  if (posts.length === 0) {
    notFound();
  }
 
  const currentPage = parseInt(page ?? "1", 10);
  const paginated = paginatePosts(posts, currentPage);
  const allTags = await getAllTags();
 
  return (
    <div className="mx-auto max-w-4xl px-4 py-12">
      <h1 className="text-3xl font-bold">
        Posts tagged &ldquo;{decodedTag}&rdquo;
      </h1>
      <p className="mt-2 text-muted-foreground">
        {paginated.totalPosts} {paginated.totalPosts === 1 ? "post" : "posts"} found
      </p>
 
      {/* Tag cloud */}
      <div className="mt-6 flex flex-wrap gap-2">
        {allTags.map((t) => (
          <TagBadge
            key={t.name}
            tag={t.name}
            count={t.count}
            clickable={t.name !== decodedTag}
          />
        ))}
      </div>
 
      {/* Bài viết đã filter */}
      <div className="mt-8 space-y-6">
        {paginated.posts.map((post) => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
 
      {/* Pagination */}
      <Pagination
        currentPage={paginated.currentPage}
        totalPages={paginated.totalPages}
        basePath={`/blog/tags/${encodeURIComponent(decodedTag)}`}
      />
    </div>
  );
}

9. Cấu Trúc Project Sau Phase 4

Project của bạn giờ trông như thế này (file mới/đã sửa được đánh dấu):

my-blog/
├── app/
│   ├── api/
│   │   ├── views/[slug]/route.ts
│   │   └── subscribe/route.ts
│   └── blog/
│       ├── page.tsx                 ← ĐÃ SỬA (search + pagination)
│       ├── [slug]/page.tsx
│       └── tags/
│           └── [tag]/
│               └── page.tsx         ← MỚI (tag index page)
├── components/
│   ├── PostCard.tsx                 ← ĐÃ SỬA (tags, reading time, featured)
│   ├── SearchBar.tsx                ← MỚI
│   ├── TagBadge.tsx                 ← MỚI
│   ├── Pagination.tsx               ← MỚI
│   ├── ViewCounter.tsx
│   └── SubscribeForm.tsx
├── lib/
│   ├── posts.ts                     ← ĐÃ SỬA (search, tags, pagination, reading time)
│   └── db/
│       ├── index.ts
│       ├── schema.ts
│       └── queries.ts
├── content/posts/
│   └── *.mdx                       ← Thêm `featured: true` để ghim bài
└── ...

10. Test Các Feature

  1. Mở /blog trên browser
  2. Gõ từ khoá vào search bar — bài viết filter theo thời gian thực
  3. Kiểm tra URL — nó phải update thành /blog?q=your-keyword
  4. Copy và mở URL trong tab mới — kết quả phải giống nhau
  5. Xoá search — tất cả bài viết hiện lại

Tags

  1. Click bất kỳ tag badge nào trên post card
  2. Phải navigate đến /blog/tags/[tag]
  3. Chỉ bài viết có tag đó mới hiển thị
  4. Tag cloud phải highlight tag hiện tại
  5. Click tag khác — filter cập nhật

Pagination

  1. Nếu có hơn 6 bài viết, pagination controls phải xuất hiện
  2. Click trang 2 — URL update thành /blog?page=2
  3. Search rồi phân trang — cả q và page params hoạt động cùng nhau
  4. Đi đến trang vượt quá tổng số — nó phải clamp về trang cuối

Reading time

  1. Mở bất kỳ bài viết nào — reading time hiển thị cạnh ngày tháng
  2. So sánh bài ngắn vs bài dài — thời gian phải khác nhau rõ rệt
  1. Thêm featured: true vào frontmatter một bài
  2. Bài đó phải xuất hiện đầu tiên trên blog page, bất kể ngày
  3. Icon pin phải hiển thị trên card

Các Vấn Đề Thường Gặp

Search không hoạt động — page không re-render

Đảm bảo blog page component đọc searchParams là prop (không phải từ hook). Server components nhận search params dưới dạng props từ Next.js.

// ✅ Đúng — server component đọc props
export default async function BlogPage({ searchParams }: BlogPageProps) {
  const { q } = await searchParams;
// ❌ Sai — useSearchParams chỉ dành cho client components
const searchParams = useSearchParams();

Tags có ký tự đặc biệt bị vỡ URL

Luôn encode tag names trong URLs và decode khi đọc:

// Encode (trong links)
`/blog/tags/${encodeURIComponent(tag)}`
 
// Decode (trong page component)
const decodedTag = decodeURIComponent(tag);

Pagination hiển thị sai số trang

Kiểm tra POSTS_PER_PAGE có đúng như mong đợi không. Nếu đang filter theo search hoặc tag, total pages phải dựa trên số lượng đã filter, không phải tổng số bài viết.

useSearchParams yêu cầu Suspense boundary

Nếu thấy lỗi build về useSearchParams, bọc component dùng nó trong <Suspense>:

<Suspense fallback={null}>
  <SearchBar />
</Suspense>

Tổng Kết

Trong phase này bạn đã:

✅ Thêm reading time estimates dựa trên word count
✅ Xây dựng hệ thống tag với tag extraction, badges, và tag index page
✅ Implement server-side search sử dụng URL search params
✅ Tạo pagination với page numbers và prev/next controls
✅ Thêm featured/pinned posts với visual indicators
✅ Thiết lập SEO metadata cho tag pages và search results

Blog của bạn giờ đã có ba feature discovery thiết yếu — tags, search, và pagination — giúp người đọc tìm và duyệt nội dung của bạn. Đây không chỉ là nice-to-have; đây là sự khác biệt giữa blog trông như danh sách file và blog trông như một sản phẩm thực sự.


Tiếp Theo Là Gì?

Trong Phase 5, bạn sẽ containerize toàn bộ ứng dụng — Next.js và PostgreSQL — với Docker Compose. Một lệnh docker compose up sẽ khởi chạy blog hoàn chỉnh ở local, và setup tương tự sẽ hoạt động trên production VPS.

Bài tiếp: Phase 5 — Docker Compose


Mục Lục Series

BàiTiêu đềTrạng thái
BLOG-1Xây Dựng Blog Cá Nhân — Lộ Trình✅ Hoàn thành
BLOG-2Phase 1: Khởi Tạo Project — Next.js 16 + ShadCN/UI✅ Hoàn thành
BLOG-3Phase 2: Render MDX Theo Yêu Cầu✅ Hoàn thành
BLOG-4Phase 3: PostgreSQL + Drizzle ORM✅ Hoàn thành
BLOG-5Phase 4: Tags, Search & Pagination✅ Bạn đang ở đây
BLOG-6Phase 5: Docker Compose✅ Hoàn thành
BLOG-7Phase 6: Deploy lên Ubuntu VPS trên Hostinger✅ Hoàn thành
BLOG-8Phase 7: Thiết Lập Domain trên Hostinger✅ Hoàn thành

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.