DEV Community

Raşit
Raşit

Posted on • Originally published at elmapicms.com

How to Create a Next.js Blog - Part 2: Table of Contents, Search, and Categories

Adding Interactive Features to Your Next.js Blog

In Part 1, we set up the foundation of your blog with a listing page. Now we'll add individual post pages, a table of contents for easy navigation, search functionality, and a category filtering system.

What You'll Build

By the end of Part 2, you'll have:

  • Individual blog post pages with MDX rendering
  • A table of contents component with active heading tracking
  • Client-side search functionality
  • Category filtering system with dedicated category pages
  • Enhanced blog listing with search and filters

Prerequisites

Before starting, make sure you've completed Part 1 and have:

  • A working Next.js blog with MDX support
  • shadcn/ui components installed
  • Sample blog posts in content/blog/
  • Basic understanding of React hooks (useState, useMemo)

Step 1: Creating Dynamic Post Pages

Let's create individual pages for each blog post using Next.js dynamic routes.

Updating posts.ts

Next, we need to add a function to get individual post data. Update lib/posts.ts:

export async function getPostData(slug: string): Promise<Post> {
  const fullPath = path.join(postsDirectory, `${slug}.mdx`)
  try {
    await fs.stat(fullPath)
  } catch (error) {
    throw new Error('Post not found')
  }
  const fileContents = await fs.readFile(fullPath, 'utf8')

  // Use gray-matter to parse the post metadata section
  const matterResult = matter(fileContents)

  // Combine the data with the id and content
  return {
    slug,
    title: matterResult.data.title,
    date: matterResult.data.date,
    description: matterResult.data.description,
    content: matterResult.content,
    image: matterResult.data.image,
    image_dark: matterResult.data.image_dark,
    category: matterResult.data.category,
  }
}
Enter fullscreen mode Exit fullscreen mode

This function reads a specific MDX file and returns both the frontmatter and content.

Creating the Dynamic Route

Create app/blog/[slug]/page.tsx:

import { getPostData, getAllPostSlugs } from '@/lib/posts'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import rehypeSlug from 'rehype-slug'

type PageParams = Promise<{ slug: string }>

export async function generateMetadata({
  params,
}: {
  params: PageParams
}): Promise<Metadata> {
  try {
    const { slug } = await params
    const post = await getPostData(slug)
    return {
      title: post.title,
      description: post.description,
    }
  } catch (e) {
    return {
      title: 'Post not found',
      description: 'The post you are looking for does not exist.',
    }
  }
}

export async function generateStaticParams() {
  const posts = await getAllPostSlugs()
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default async function PostPage({ params }: { params: PageParams }) {
  const { slug } = await params
  try {
    const post = await getPostData(slug)
    const words = post.content.split(/\s+/g).filter(Boolean).length
    const readingTime = Math.ceil(words / 200) // ~200 words per minute

    return (
      <div className="container mx-auto px-4 py-16 max-w-4xl">
        <Link
          href="/blog"
          className="mb-4 flex items-center text-sm text-muted-foreground hover:underline"
        >
          <ArrowLeft className="mr-2 h-4 w-4" />
          Back to blog
        </Link>

        <h1 className="mb-2 text-3xl font-extrabold leading-tight tracking-tighter md:text-4xl">
          {post.title}
        </h1>
        <p className="mb-6 text-sm text-muted-foreground">
          {formatDate(post.date)} · {readingTime} min read
        </p>

        <article className="prose dark:prose-invert max-w-none">
          <MDXRemote
            source={post.content}
            options={{
              mdxOptions: {
                rehypePlugins: [rehypeSlug],
              },
            }}
          />
        </article>
      </div>
    )
  } catch (e) {
    notFound()
  }
}
Enter fullscreen mode Exit fullscreen mode

Key features:

  • generateStaticParams() - Pre-generates all post pages at build time for optimal performance
  • generateMetadata() - Creates SEO-friendly metadata for each post
  • MDXRemote - Renders MDX content with React Server Components
  • notFound() - Shows 404 page if post doesn't exist
  • Reading time - Calculates estimated reading time

Adding the formatDate Utility

Let's add a utility function to format dates. Add this to lib/utils.ts:

export function formatDate(date: string): string {
  return new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  })
}
Enter fullscreen mode Exit fullscreen mode

This function takes a date string (like '2026-01-10') and returns a nicely formatted date (like 'January 10, 2026').

Installing Required Packages

Make sure you have the typography plugin for prose styling:

npm install @tailwindcss/typography
Enter fullscreen mode Exit fullscreen mode

Next.js 16 uses Tailwind CSS v4, which configures plugins in CSS rather than a config file. Add the typography plugin to app/globals.css:

@import "tailwindcss";
@plugin "@tailwindcss/typography";
Enter fullscreen mode Exit fullscreen mode

Add the @plugin line after the @import "tailwindcss" line that's already in your globals.css.

The prose class provides beautiful typography for your MDX content.


Step 2: Implementing Table of Contents

A table of contents helps readers navigate long blog posts. We'll create a component that extracts headings and highlights the active section.

Installing rehype-slug

First, install the rehype-slug package to automatically generate IDs for headings:

npm install rehype-slug
Enter fullscreen mode Exit fullscreen mode

Creating the TOC Utility

Create lib/toc.ts:

export interface TocItem {
  id: string
  text: string
  level: number
}

function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '') // Remove special characters
    .replace(/\s+/g, '-') // Replace spaces with hyphens
    .replace(/-+/g, '-') // Replace multiple hyphens with single
    .trim()
}

export function extractHeadings(content: string): TocItem[] {
  // Match markdown headings: ## Heading Text or ### Heading Text
  const headingRegex = /^(#{2,4})\s+(.+)$/gm
  const headings: TocItem[] = []
  let match

  while ((match = headingRegex.exec(content)) !== null) {
    const level = match[1].length // Number of # symbols
    const text = match[2].trim()
    const id = slugify(text)

    if (id && text) {
      headings.push({
        id,
        text,
        level,
      })
    }
  }

  return headings
}
Enter fullscreen mode Exit fullscreen mode

This utility:

  • slugify() - Converts heading text to URL-friendly IDs (matching what rehype-slug generates)
  • extractHeadings() - Parses markdown headings (##, ###, ####) from the raw MDX content

Creating the Table of Contents Component

Create components/table-of-contents.tsx:

'use client'

import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'

interface TocItem {
  id: string
  text: string
  level: number
}

interface TableOfContentsProps {
  items: TocItem[]
}

export function TableOfContents({ items }: TableOfContentsProps) {
  const [activeId, setActiveId] = useState<string>('')

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setActiveId(entry.target.id)
          }
        })
      },
      {
        rootMargin: '-128px 0px -66% 0px', // Account for fixed header
        threshold: 0,
      }
    )

    // Observe all heading elements
    items.forEach((item) => {
      const element = document.getElementById(item.id)
      if (element) {
        observer.observe(element)
      }
    })

    // Check scroll position to highlight "Introduction" when at top
    const handleScroll = () => {
      if (window.scrollY < 200) {
        setActiveId('')
      }
    }

    window.addEventListener('scroll', handleScroll)
    handleScroll() // Check initial position

    return () => {
      items.forEach((item) => {
        const element = document.getElementById(item.id)
        if (element) {
          observer.unobserve(element)
        }
      })
      window.removeEventListener('scroll', handleScroll)
    }
  }, [items])

  const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id?: string) => {
    e.preventDefault()
    if (id) {
      const element = document.getElementById(id)
      if (element) {
        const offset = 128 // Match scroll-margin-top
        const elementPosition = element.getBoundingClientRect().top
        const offsetPosition = elementPosition + window.pageYOffset - offset

        window.scrollTo({
          top: offsetPosition,
          behavior: 'smooth',
        })
      }
    } else {
      // Scroll to top
      window.scrollTo({
        top: 0,
        behavior: 'smooth',
      })
    }
  }

  if (items.length === 0) {
    return null
  }

  return (
    <aside className="hidden xl:block w-64 flex-shrink-0">
      <div className="sticky top-32">
        <h2 className="text-sm font-semibold text-foreground mb-4">
          Table of Contents
        </h2>
        <nav className="space-y-1.5 max-h-[calc(100vh-12rem)] overflow-y-auto">
          <a
            href="#"
            onClick={(e) => handleClick(e)}
            className={cn(
              'block text-sm transition-colors py-1 rounded-md pl-2 font-medium',
              activeId === '' || !activeId
                ? 'text-foreground font-medium bg-accent/50'
                : 'text-muted-foreground hover:text-foreground hover:bg-accent/30'
            )}
          >
            Introduction
          </a>
          {items.map((item) => (
            <a
              key={item.id}
              href={`#${item.id}`}
              onClick={(e) => handleClick(e, item.id)}
              className={cn(
                'block text-sm transition-colors py-1 rounded-md',
                item.level === 2 && 'pl-2 font-medium',
                item.level === 3 && 'pl-6 text-muted-foreground',
                item.level === 4 && 'pl-10 text-muted-foreground',
                activeId === item.id
                  ? 'text-foreground font-medium bg-accent/50'
                  : 'text-muted-foreground hover:text-foreground hover:bg-accent/30'
              )}
            >
              {item.text}
            </a>
          ))}
        </nav>
      </div>
    </aside>
  )
}
Enter fullscreen mode Exit fullscreen mode

Key features:

  • IntersectionObserver - Tracks which heading is currently visible
  • Smooth scrolling - Smoothly scrolls to headings when clicked
  • Active state - Highlights the current section
  • Responsive - Hidden on smaller screens, visible on xl screens
  • Sticky positioning - Stays visible while scrolling

Updating the Post Page

Update app/blog/[slug]/page.tsx to include the TOC. Here's the complete updated file:

import { getPostData, getAllPostSlugs } from '@/lib/posts'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { extractHeadings } from '@/lib/toc'
import { TableOfContents } from '@/components/table-of-contents'
import rehypeSlug from 'rehype-slug'

type PageParams = Promise<{ slug: string }>

export async function generateMetadata({
  params,
}: {
  params: PageParams
}): Promise<Metadata> {
  try {
    const { slug } = await params
    const post = await getPostData(slug)
    return {
      title: post.title,
      description: post.description,
    }
  } catch (e) {
    return {
      title: 'Post not found',
      description: 'The post you are looking for does not exist.',
    }
  }
}

export async function generateStaticParams() {
  const posts = await getAllPostSlugs()
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default async function PostPage({ params }: { params: PageParams }) {
  const { slug } = await params
  try {
    const post = await getPostData(slug)
    const words = post.content.split(/\s+/g).filter(Boolean).length
    const readingTime = Math.ceil(words / 200) // ~200 words per minute
    const tocItems = extractHeadings(post.content)

    return (
      <div className="container mx-auto px-4 py-16">
        <div className="flex gap-8">
          <TableOfContents items={tocItems} />
          <div className="flex-1 max-w-4xl">
            <Link
              href="/blog"
              className="mb-4 flex items-center text-sm text-muted-foreground hover:underline"
            >
              <ArrowLeft className="mr-2 h-4 w-4" />
              Back to blog
            </Link>

            <h1 className="mb-2 text-3xl font-extrabold leading-tight tracking-tighter md:text-4xl">
              {post.title}
            </h1>
            <p className="mb-6 text-sm text-muted-foreground">
              {formatDate(post.date)} · {readingTime} min read
            </p>

            <article className="prose dark:prose-invert max-w-none">
              <MDXRemote
                source={post.content}
                options={{
                  mdxOptions: {
                    rehypePlugins: [rehypeSlug],
                  },
                }}
              />
            </article>
          </div>
        </div>
      </div>
    )
  } catch (e) {
    notFound()
  }
}
Enter fullscreen mode Exit fullscreen mode

What changed:

  • Added imports for extractHeadings, TableOfContents, and rehypeSlug
  • Added tocItems extraction from post content
  • Wrapped the layout with a flex container for the TOC sidebar
  • Removed max-w-4xl from the container and moved it to the content div
  • Added rehypeSlug plugin to MDXRemote to auto-generate heading IDs

Step 3: Adding Search Functionality

Let's add client-side search to filter posts by title and description.

Creating the BlogList Component

Create components/blog-list.tsx:

"use client"

import { useState, useMemo } from 'react'
import Link from 'next/link'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Search } from 'lucide-react'
import { PostMatter } from '@/lib/posts'
import { formatDate } from '@/lib/utils'

interface BlogListProps {
  posts: PostMatter[]
}

export function BlogList({ posts }: BlogListProps) {
  const [searchQuery, setSearchQuery] = useState('')

  const filteredPosts = useMemo(() => {
    if (!searchQuery.trim()) {
      return posts
    }

    const query = searchQuery.toLowerCase()
    return posts.filter(
      (post) =>
        post.title.toLowerCase().includes(query) ||
        post.description.toLowerCase().includes(query)
    )
  }, [posts, searchQuery])

  return (
    <div className="w-full">
      {/* Search Input */}
      <div className="mb-6">
        <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={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            className="pl-10"
          />
        </div>
      </div>

      {/* Results count */}
      {searchQuery && (
        <p className="mb-6 text-sm text-muted-foreground">
          {filteredPosts.length} {filteredPosts.length === 1 ? 'post' : 'posts'} found
        </p>
      )}

      {/* Posts List */}
      {filteredPosts.length === 0 ? (
        <div className="py-12 text-center">
          <p className="text-muted-foreground">No posts found matching your search.</p>
        </div>
      ) : (
        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
          {filteredPosts.map((post) => (
            <Link
              href={`/blog/${post.slug}`}
              key={post.slug}
              className="group block"
            >
              <article className="h-full rounded-lg border bg-card p-6 transition-colors hover:border-primary">
                {post.category && (
                  <Badge variant="secondary" className="mb-2">
                    {post.category}
                  </Badge>
                )}
                <h2 className="mb-2 text-xl font-semibold group-hover:text-primary transition-colors">
                  {post.title}
                </h2>
                <p className="mb-4 text-sm text-muted-foreground">
                  {post.description}
                </p>
                <time className="text-xs text-muted-foreground">
                  {formatDate(post.date)}
                </time>
              </article>
            </Link>
          ))}
        </div>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Key features:

  • useState - Manages search query state
  • useMemo - Memoizes filtered results for performance
  • Case-insensitive search - Searches both title and description
  • Real-time filtering - Updates as you type
  • Empty state - Shows message when no results found

Updating the Blog Page

Now let's update app/blog/page.tsx to use the BlogList component instead of the inline grid. Replace the entire file:

import { getSortedPostsData } from '@/lib/posts'
import { BlogList } from '@/components/blog-list'
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Blog',
  description: 'Read our latest blog posts and tutorials.',
}

export default async function Blog() {
  const posts = await getSortedPostsData()

  return (
    <div className="container mx-auto px-4 py-16">
      <div className="mb-12 text-center">
        <h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
          Blog
        </h1>
        <p className="mt-6 text-lg text-muted-foreground">
          Read our latest posts and tutorials
        </p>
      </div>
      <BlogList posts={posts} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

What changed from Part 1:

  • Removed Link and Badge imports (now handled inside BlogList)
  • Added BlogList import
  • Replaced the inline <div className="grid ..."> with posts mapping with the <BlogList posts={posts} /> component

The BlogList component now handles rendering the posts grid, search functionality, and will later handle category filtering.


Step 4: Building the Category System

Now let's add category filtering with dedicated category pages.

Adding Category Functions

Update lib/posts.ts:

export async function getAllCategories(): Promise<string[]> {
  const posts = await getSortedPostsData()
  const categories = posts
    .map((post) => post.category)
    .filter((category): category is string => Boolean(category))
  return Array.from(new Set(categories)).sort()
}

export async function getPostsByCategory(category: string): Promise<PostMatter[]> {
  const posts = await getSortedPostsData()
  return posts.filter((post) => post.category === category)
}

export async function getCategoryBySlug(slug: string): Promise<string | null> {
  const categories = await getAllCategories()
  const matchedCategory = categories.find(
    (category) => slugify(category) === slug.toLowerCase()
  )
  return matchedCategory || null
}
Enter fullscreen mode Exit fullscreen mode

Add the slugify utility to lib/utils.ts:

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '') // Remove special characters
    .replace(/[\s_-]+/g, '-') // Replace spaces, underscores, and multiple hyphens with single hyphen
    .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
}

export function getCategorySlug(category: string): string {
  return slugify(category)
}
Enter fullscreen mode Exit fullscreen mode

Now add the import at the top of lib/posts.ts:

import { slugify } from '@/lib/utils'
Enter fullscreen mode Exit fullscreen mode

Creating Category Pages

Create app/blog/category/[category]/page.tsx:

import { getSortedPostsData, getAllCategories, getCategoryBySlug } from '@/lib/posts'
import { getCategorySlug } from '@/lib/utils'
import { BlogList } from '@/components/blog-list'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'

type PageParams = Promise<{ category: string }>

export async function generateMetadata({
  params,
}: {
  params: PageParams
}): Promise<Metadata> {
  const { category } = await params
  const matchedCategory = await getCategoryBySlug(category)

  if (!matchedCategory) {
    return {
      title: 'Category not found',
      description: 'The category you are looking for does not exist.',
    }
  }

  return {
    title: `${matchedCategory} | Blog`,
    description: `Browse all ${matchedCategory.toLowerCase()} posts.`,
  }
}

export async function generateStaticParams() {
  const categories = await getAllCategories()
  return categories.map((category) => ({
    category: getCategorySlug(category),
  }))
}

export default async function CategoryPage({ params }: { params: PageParams }) {
  const { category } = await params
  const matchedCategory = await getCategoryBySlug(category)

  if (!matchedCategory) {
    notFound()
  }

  // Get all posts - BlogList will filter by currentCategory
  const posts = await getSortedPostsData()

  return (
    <div className="container mx-auto px-4 py-16">
      <div className="mb-12 text-center">
        <h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
          {matchedCategory}
        </h1>
        <p className="mt-6 text-lg text-muted-foreground">
          Browse all {matchedCategory.toLowerCase()} posts
        </p>
      </div>
      <BlogList posts={posts} currentCategory={matchedCategory} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Key point: We pass all posts to BlogList along with currentCategory. This way, BlogList can display all category filter buttons while showing only the posts from the selected category.

Adding Category Filters to BlogList

Update components/blog-list.tsx to include category filters. Here's the complete updated file:

"use client"

import { useState, useMemo } from 'react'
import Link from 'next/link'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
import { PostMatter } from '@/lib/posts'
import { formatDate, getCategorySlug } from '@/lib/utils'

interface BlogListProps {
  posts: PostMatter[]
  currentCategory?: string | null
}

export function BlogList({ posts, currentCategory = null }: BlogListProps) {
  const [searchQuery, setSearchQuery] = useState('')

  // Get unique categories from posts
  const categories = useMemo(() => {
    const cats = posts
      .map((post) => post.category)
      .filter((category): category is string => Boolean(category))
    return Array.from(new Set(cats)).sort()
  }, [posts])

  const filteredPosts = useMemo(() => {
    let filtered = posts

    // Filter by category if currentCategory is set
    if (currentCategory) {
      filtered = filtered.filter((post) => post.category === currentCategory)
    }

    // Filter by search query
    if (searchQuery.trim()) {
      const query = searchQuery.toLowerCase()
      filtered = filtered.filter(
        (post) =>
          post.title.toLowerCase().includes(query) ||
          post.description.toLowerCase().includes(query)
      )
    }

    return filtered
  }, [posts, searchQuery, currentCategory])

  return (
    <div className="w-full">
      {/* Search Input */}
      <div className="mb-6">
        <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={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            className="pl-10"
          />
        </div>
      </div>

      {/* Category Filters */}
      {categories.length > 0 && (
        <div className="mb-8">
          <div className="flex flex-wrap gap-2">
            <Link href="/blog">
              <Button
                variant={currentCategory === null ? 'default' : 'outline'}
                size="sm"
              >
                All
              </Button>
            </Link>
            {categories.map((category) => {
              const categoryUrl = `/blog/category/${getCategorySlug(category)}`
              const isActive = currentCategory === category
              return (
                <Link key={category} href={categoryUrl}>
                  <Button
                    variant={isActive ? 'default' : 'outline'}
                    size="sm"
                  >
                    {category}
                  </Button>
                </Link>
              )
            })}
          </div>
        </div>
      )}

      {/* Results count */}
      {(searchQuery || currentCategory) && (
        <p className="mb-6 text-sm text-muted-foreground">
          {filteredPosts.length} {filteredPosts.length === 1 ? 'post' : 'posts'} found
          {currentCategory && ` in "${currentCategory}"`}
        </p>
      )}

      {/* Posts List */}
      {filteredPosts.length === 0 ? (
        <div className="py-12 text-center">
          <p className="text-muted-foreground">No posts found matching your search.</p>
        </div>
      ) : (
        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
          {filteredPosts.map((post) => (
            <Link
              href={`/blog/${post.slug}`}
              key={post.slug}
              className="group block"
            >
              <article className="h-full rounded-lg border bg-card p-6 transition-colors hover:border-primary">
                {post.category && (
                  <Badge variant="secondary" className="mb-2">
                    {post.category}
                  </Badge>
                )}
                <h2 className="mb-2 text-xl font-semibold group-hover:text-primary transition-colors">
                  {post.title}
                </h2>
                <p className="mb-4 text-sm text-muted-foreground">
                  {post.description}
                </p>
                <time className="text-xs text-muted-foreground">
                  {formatDate(post.date)}
                </time>
              </article>
            </Link>
          ))}
        </div>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

What changed from the previous version:

  • Added Button import from shadcn/ui
  • Added getCategorySlug import from utils
  • Added currentCategory prop to the interface
  • Added categories extraction using useMemo
  • Updated filteredPosts to filter by category when set
  • Added category filter buttons section
  • Updated results count to show current category

What's Next

Congratulations! You've completed Part 2. You now have:

  • Individual post pages with MDX rendering
  • Table of contents with active heading tracking
  • Search functionality
  • Category filtering system

In Part 3, we'll add:

  • Related posts feature
  • Share buttons
  • Enhanced code blocks
  • SEO optimization
  • Image optimization

Top comments (0)