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 “{decodedTag}”
</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>
);
}4. Search
Có hai cách thêm search vào blog Next.js:
| Approach | Chạy ở đâu | URL shareable | SEO | Complexity |
|---|---|---|---|---|
Client-side (useState) | Browser | ❌ Không | ❌ Không index | Thấp |
| Server-side (URL search params) | Server | ✅ Có | ✅ Được index | Thấ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
Xxoá 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 “{q}”
</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
Suspensebọc SearchBar? HookuseSearchParams()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 “{q}”
</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.
6. Featured / Pinned Posts
Đô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 “{decodedTag}”
</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
Search
- Mở
/blogtrên browser - Gõ từ khoá vào search bar — bài viết filter theo thời gian thực
- Kiểm tra URL — nó phải update thành
/blog?q=your-keyword - Copy và mở URL trong tab mới — kết quả phải giống nhau
- Xoá search — tất cả bài viết hiện lại
Tags
- Click bất kỳ tag badge nào trên post card
- Phải navigate đến
/blog/tags/[tag] - Chỉ bài viết có tag đó mới hiển thị
- Tag cloud phải highlight tag hiện tại
- Click tag khác — filter cập nhật
Pagination
- Nếu có hơn 6 bài viết, pagination controls phải xuất hiện
- Click trang 2 — URL update thành
/blog?page=2 - Search rồi phân trang — cả
qvàpageparams hoạt động cùng nhau - Đi đến trang vượt quá tổng số — nó phải clamp về trang cuối
Reading time
- Mở bất kỳ bài viết nào — reading time hiển thị cạnh ngày tháng
- So sánh bài ngắn vs bài dài — thời gian phải khác nhau rõ rệt
Featured posts
- Thêm
featured: truevào frontmatter một bài - Bài đó phải xuất hiện đầu tiên trên blog page, bất kể ngày
- 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ài | Tiêu đề | Trạng thái |
|---|---|---|
| BLOG-1 | Xây Dựng Blog Cá Nhân — Lộ Trình | ✅ Hoàn thành |
| BLOG-2 | Phase 1: Khởi Tạo Project — Next.js 16 + ShadCN/UI | ✅ Hoàn thành |
| BLOG-3 | Phase 2: Render MDX Theo Yêu Cầu | ✅ Hoàn thành |
| BLOG-4 | Phase 3: PostgreSQL + Drizzle ORM | ✅ Hoàn thành |
| BLOG-5 | Phase 4: Tags, Search & Pagination | ✅ Bạn đang ở đây |
| BLOG-6 | Phase 5: Docker Compose | ✅ Hoàn thành |
| BLOG-7 | Phase 6: Deploy lên Ubuntu VPS trên Hostinger | ✅ Hoàn thành |
| BLOG-8 | Phase 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.