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

This is Phase 4 of the Build a Personal Blog series. Your blog now renders MDX content and stores dynamic data in PostgreSQL. But readers still can't find anything — there's no way to filter by topic, search for a keyword, or browse page by page. Time to fix that.
Series: Build a Personal Blog — Complete Roadmap
Previous: Phase 3 — PostgreSQL + Drizzle ORM
Next: Phase 5 — Docker Compose
Source Code: GitHub — personal-blog-phase-4
What You'll Build
By the end of this phase:
✅ A tag system that extracts tags from frontmatter and links to a tag index page
✅ A tag index page at /blog/tags/[tag] showing filtered posts
✅ A search bar using URL search params (server-side, RSC-friendly)
✅ Pagination with prev/next controls and page numbers
✅ Reading time estimates on every post
✅ Featured/pinned posts via a frontmatter flag
✅ SEO metadata for tag pages and paginated listings
Time commitment: 3–5 hours
Prerequisites: Phase 3 — PostgreSQL + Drizzle ORM
1. Extend the Post Type
First, update your post types to support the new features. Open lib/posts.ts and extend the 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; // NEW — pin posts to the top
}
export interface Post {
slug: string;
frontmatter: PostFrontmatter;
content: string;
readingTime: number; // NEW — estimated minutes to read
}2. Reading Time Calculation
A reading time estimate is one of those small details that makes a blog feel polished. The math is simple: count the words, divide by average reading speed (~200 words per minute for technical content).
Add this helper to lib/posts.ts:
// lib/posts.ts
/**
* Estimate reading time in minutes.
* Strips code blocks and frontmatter, counts remaining words.
* Uses 200 wpm for technical content (slower than casual reading).
*/
function calculateReadingTime(content: string): number {
// Remove code blocks (they're scanned, not read word-by-word)
const withoutCode = content.replace(/```[\s\S]*?```/g, "");
// Remove frontmatter
const withoutFrontmatter = withoutCode.replace(/^---[\s\S]*?---/, "");
// Count words
const words = withoutFrontmatter
.trim()
.split(/\s+/)
.filter((word) => word.length > 0).length;
return Math.max(1, Math.ceil(words / 200));
}Now update getAllPosts to include 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),
};
});
// Sort: featured posts first, then by date descending
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();
});
}Similarly, update getPostBySlug to return 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),
};
}Display reading time
Add it next to the date on your post page and post cards:
// components/PostCard.tsx (partial)
<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. Tag System
3.1 Extract All Tags
Add a helper that collects every unique tag from all posts and counts how many posts use each one:
// lib/posts.ts
export interface TagInfo {
name: string;
count: number;
}
/**
* Get all unique tags with post counts, sorted by count descending.
*/
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);
}
/**
* Get all posts that have a specific tag.
*/
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
Create a reusable tag badge using ShadCN/UI's Badge component:
// 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 Display Tags on Post Cards
Add tags to your PostCard component:
// components/PostCard.tsx (partial — add below the 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>We show a maximum of 4 tags on the card to keep the layout clean. The full tag list is visible on the post detail page.
3.4 Tag Index Page
Create the dynamic route for /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 — show all tags for discovery */}
<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>
{/* Filtered posts */}
<div className="mt-8 space-y-6">
{posts.map((post) => (
<PostCard key={post.slug} post={post} />
))}
</div>
</div>
);
}4. Search
There are two ways to add search to a Next.js blog:
| Approach | Where it runs | URL shareable | SEO | Complexity |
|---|---|---|---|---|
Client-side (useState) | Browser | ❌ No | ❌ Not indexed | Low |
| Server-side (URL search params) | Server | ✅ Yes | ✅ Indexed | Low |
We'll use server-side search with URL search params — it's just as easy to build, and the URL is shareable (/blog?q=docker). Since your posts are MDX files on disk, the "search engine" is just string matching on title, description, and tags.
4.1 Search Function
Add a search helper to lib/posts.ts:
// lib/posts.ts
/**
* Search posts by query string.
* Matches against title, description, and tags.
* Case-insensitive, returns results sorted by 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));
});
}The search splits the query into words and requires all words to match (AND logic). So searching "nextjs docker" finds posts that mention both "nextjs" and "docker" in their title, description, or tags.
4.2 SearchBar Component
Create a client component that updates the 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 to page 1 on new search
} 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>
);
}Key design decisions:
- URL-driven: The search query lives in the URL (
?q=docker), not in React state. This makes search results shareable and bookmarkable. useTransition: Keeps the input responsive while the page re-renders server-side. The user sees a spinner instead of a frozen input.- Reset pagination: When the search query changes, we delete the
pageparam so results start from page 1. - Clear button: The
Xicon clears the search and shows all posts again.
4.3 Wire Search into the Blog Page
Update your blog listing page to read the 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>
{/* Search results info */}
{q && (
<p className="mt-4 text-sm text-muted-foreground">
{posts.length} {posts.length === 1 ? "result" : "results"} for “{q}”
</p>
)}
{/* Post list */}
<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">
No posts found. Try a different search term.
</p>
)}
</div>
</div>
);
}Why
Suspensearound SearchBar? TheuseSearchParams()hook requires a Suspense boundary when used in a Server Component page. Without it, Next.js throws a build error.
5. Pagination
Once you have more than a dozen posts, showing all of them on a single page creates a wall of content. Pagination breaks the list into digestible pages.
5.1 Pagination Logic
Add pagination helpers to 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;
}
/**
* Paginate an array of 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 a pagination UI with 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;
}
// Show up to 5 page numbers centered around current page
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"
>
{/* First page */}
<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>
{/* Previous page */}
<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>
{/* Page numbers */}
{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>
))}
{/* Next page */}
<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>
{/* Last page */}
<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 with Pagination
Integrate pagination into the 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 for 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>
{/* Search results info */}
{q && (
<p className="mt-4 text-sm text-muted-foreground">
{paginated.totalPosts} {paginated.totalPosts === 1 ? "result" : "results"} for “{q}”
</p>
)}
{/* Post list */}
<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">
No posts found. Try a different search term.
</p>
)}
</div>
{/* Pagination controls */}
<Pagination
currentPage={paginated.currentPage}
totalPages={paginated.totalPages}
basePath="/blog"
searchParams={paginationParams}
/>
</div>
);
}Now your blog URL tells the full story: /blog?q=docker&page=2 means "page 2 of search results for docker". Anyone can bookmark, share, or link to this URL.
6. Featured / Pinned Posts
Sometimes you want a post to stay at the top of the list regardless of date — an introduction post, a popular tutorial, or a series overview.
6.1 Frontmatter Flag
Add featured: true to any post's frontmatter:
---
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 Sorting Logic
The getAllPosts() function already handles this — featured posts sort first, then by date:
// Already in our updated getAllPosts()
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 Visual Indicator
Show a small pin icon on featured posts in the PostCard:
// components/PostCard.tsx (partial — add to the card header)
import { Pin } from "lucide-react";
// Inside the 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 for Tag Pages
Every tag page should have proper metadata for search engines. We already added generateMetadata in the tag page, but let's make it more comprehensive:
// 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 for Paginated Pages
For the main blog page, add dynamic metadata that handles pagination:
// app/blog/page.tsx (add at the top)
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. Add Pagination to Tag Pages
Tag pages with many posts should also be paginated. The approach is identical:
// 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>
{/* Filtered posts */}
<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. Project Structure After Phase 4
Your project should now look like this (new/modified files marked):
my-blog/
├── app/
│ ├── api/
│ │ ├── views/[slug]/route.ts
│ │ └── subscribe/route.ts
│ └── blog/
│ ├── page.tsx ← MODIFIED (search + pagination)
│ ├── [slug]/page.tsx
│ └── tags/
│ └── [tag]/
│ └── page.tsx ← NEW (tag index page)
├── components/
│ ├── PostCard.tsx ← MODIFIED (tags, reading time, featured)
│ ├── SearchBar.tsx ← NEW
│ ├── TagBadge.tsx ← NEW
│ ├── Pagination.tsx ← NEW
│ ├── ViewCounter.tsx
│ └── SubscribeForm.tsx
├── lib/
│ ├── posts.ts ← MODIFIED (search, tags, pagination, reading time)
│ └── db/
│ ├── index.ts
│ ├── schema.ts
│ └── queries.ts
├── content/posts/
│ └── *.mdx ← Add `featured: true` to pin posts
└── ...10. Testing Your Features
Search
- Open
/blogin your browser - Type a keyword in the search bar — posts filter in real-time
- Check the URL — it should update to
/blog?q=your-keyword - Copy and open the URL in a new tab — same results should appear
- Clear the search — all posts return
Tags
- Click any tag badge on a post card
- You should navigate to
/blog/tags/[tag] - Only posts with that tag should appear
- The tag cloud should highlight the current tag
- Click a different tag — the filter updates
Pagination
- If you have more than 6 posts, pagination controls should appear
- Click page 2 — the URL updates to
/blog?page=2 - Search for something, then paginate — both
qandpageparams work together - Go to a page beyond the total — it should clamp to the last page
Reading time
- Open any post — reading time should appear next to the date
- Check a short post vs a long post — times should differ meaningfully
Featured posts
- Add
featured: trueto a post's frontmatter - That post should appear first on the blog page, regardless of date
- The pin icon should be visible on the card
Common Issues
Search doesn't work — page doesn't re-render
Make sure your blog page component reads searchParams as a prop (not from a hook). Server components receive search params as props from Next.js.
// ✅ Correct — server component reads props
export default async function BlogPage({ searchParams }: BlogPageProps) {
const { q } = await searchParams;// ❌ Wrong — useSearchParams is for client components only
const searchParams = useSearchParams();Tags with special characters break the URL
Always encode tag names in URLs and decode them when reading:
// Encoding (in links)
`/blog/tags/${encodeURIComponent(tag)}`
// Decoding (in page component)
const decodedTag = decodeURIComponent(tag);Pagination shows wrong page count
Check that POSTS_PER_PAGE matches what you expect. If you're filtering by search or tag, the total pages should be based on the filtered count, not the total post count.
useSearchParams requires Suspense boundary
If you see a build error about useSearchParams, wrap the component that uses it in <Suspense>:
<Suspense fallback={null}>
<SearchBar />
</Suspense>Summary
In this phase you:
✅ Added reading time estimates based on word count
✅ Built a tag system with tag extraction, badges, and a tag index page
✅ Implemented server-side search using URL search params
✅ Created pagination with page numbers and prev/next controls
✅ Added featured/pinned posts with visual indicators
✅ Set up SEO metadata for tag pages and search results
Your blog now has the three essential discovery features — tags, search, and pagination — that help readers find and browse your content. These aren't just nice-to-haves; they're the difference between a blog that feels like a list of files and one that feels like a real product.
What's Next
In Phase 5, you'll containerize the entire application — Next.js and PostgreSQL — with Docker Compose. One docker compose up will spin up your complete blog locally, and the same setup will work on your production VPS.
Next Post: Phase 5 — Docker Compose
Series Index
| Post | Title | Status |
|---|---|---|
| BLOG-1 | Build a Personal Blog — Roadmap | ✅ Complete |
| BLOG-2 | Phase 1: Project Setup — Next.js 16 + ShadCN/UI | ✅ Complete |
| BLOG-3 | Phase 2: MDX On-Demand Rendering | ✅ Complete |
| BLOG-4 | Phase 3: PostgreSQL + Drizzle ORM | ✅ Complete |
| BLOG-5 | Phase 4: Tags, Search & Pagination | ✅ You are here |
| BLOG-6 | Phase 5: Docker Compose | ✅ Complete |
| BLOG-7 | Phase 6: Deploy to Ubuntu VPS on Hostinger | ✅ Complete |
| BLOG-8 | Phase 7: Custom Domain Setup on Hostinger | ✅ Complete |
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.