Back to blog

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

nextjsreacttypescriptblogshadcn
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 &ldquo;{decodedTag}&rdquo;
      </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>
  );
}

There are two ways to add search to a Next.js blog:

ApproachWhere it runsURL shareableSEOComplexity
Client-side (useState)Browser❌ No❌ Not indexedLow
Server-side (URL search params)Server✅ Yes✅ IndexedLow

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 page param so results start from page 1.
  • Clear button: The X icon 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 &ldquo;{q}&rdquo;
        </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 Suspense around SearchBar? The useSearchParams() 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 &ldquo;{q}&rdquo;
        </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.


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 &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>
 
      {/* 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

  1. Open /blog in your browser
  2. Type a keyword in the search bar — posts filter in real-time
  3. Check the URL — it should update to /blog?q=your-keyword
  4. Copy and open the URL in a new tab — same results should appear
  5. Clear the search — all posts return

Tags

  1. Click any tag badge on a post card
  2. You should navigate to /blog/tags/[tag]
  3. Only posts with that tag should appear
  4. The tag cloud should highlight the current tag
  5. Click a different tag — the filter updates

Pagination

  1. If you have more than 6 posts, pagination controls should appear
  2. Click page 2 — the URL updates to /blog?page=2
  3. Search for something, then paginate — both q and page params work together
  4. Go to a page beyond the total — it should clamp to the last page

Reading time

  1. Open any post — reading time should appear next to the date
  2. Check a short post vs a long post — times should differ meaningfully
  1. Add featured: true to a post's frontmatter
  2. That post should appear first on the blog page, regardless of date
  3. 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

PostTitleStatus
BLOG-1Build a Personal Blog — Roadmap✅ Complete
BLOG-2Phase 1: Project Setup — Next.js 16 + ShadCN/UI✅ Complete
BLOG-3Phase 2: MDX On-Demand Rendering✅ Complete
BLOG-4Phase 3: PostgreSQL + Drizzle ORM✅ Complete
BLOG-5Phase 4: Tags, Search & Pagination✅ You are here
BLOG-6Phase 5: Docker Compose✅ Complete
BLOG-7Phase 6: Deploy to Ubuntu VPS on Hostinger✅ Complete
BLOG-8Phase 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.