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,
}
}
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()
}
}
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',
})
}
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
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";
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
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
}
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>
)
}
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()
}
}
What changed:
- Added imports for
extractHeadings,TableOfContents, andrehypeSlug - Added
tocItemsextraction from post content - Wrapped the layout with a flex container for the TOC sidebar
- Removed
max-w-4xlfrom the container and moved it to the content div - Added
rehypeSlugplugin 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>
)
}
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>
)
}
What changed from Part 1:
- Removed
LinkandBadgeimports (now handled inside BlogList) - Added
BlogListimport - 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
}
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)
}
Now add the import at the top of lib/posts.ts:
import { slugify } from '@/lib/utils'
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>
)
}
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>
)
}
What changed from the previous version:
- Added
Buttonimport from shadcn/ui - Added
getCategorySlugimport from utils - Added
currentCategoryprop to the interface - Added
categoriesextraction usinguseMemo - Updated
filteredPoststo 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)