Categories and Filtering
Category-Based Navigation
Most blogs organize content by categories. Let's add:
- Category listing in the sidebar/header
- Individual category archive pages
- Active state for current category
Creating Category Pages
Create the category route structure:
mkdir -p src/app/category/[slug]
touch src/app/category/[slug]/page.tsxCategory Archive Page
// src/app/category/[slug]/page.tsx
import { notFound } from 'next/navigation';
import Link from 'next/link';
import {
getPosts,
getCategoryBySlug,
getAllCategorySlugs
} from '@/lib/api';
import { PostCard } from '@/components/PostCard';
import type { Metadata } from 'next';
interface CategoryPageProps {
params: { slug: string };
searchParams: { page?: string };
}
// Generate static params
export async function generateStaticParams() {
const slugs = await getAllCategorySlugs();
return slugs.map((slug) => ({ slug }));
}
// Generate metadata
export async function generateMetadata({
params
}: CategoryPageProps): Promise<Metadata> {
const category = await getCategoryBySlug(params.slug);
if (!category) {
return { title: 'Category Not Found' };
}
return {
title: `${category.name} | Blog Categories`,
description: category.description || `Browse all posts in ${category.name}`,
};
}
export default async function CategoryPage({
params,
searchParams
}: CategoryPageProps) {
const category = await getCategoryBySlug(params.slug);
if (!category) {
notFound();
}
const currentPage = Number(searchParams.page) || 1;
const { posts, totalPages } = await getPosts({
perPage: 9,
page: currentPage,
categoryId: category.id,
});
return (
<main className="container mx-auto px-4 py-8">
{/* Category Header */}
<header className="mb-8">
<Link
href="/blog"
className="text-blue-600 hover:underline mb-2 inline-block"
>
← All Posts
</Link>
<h1 className="text-4xl font-bold">{category.name}</h1>
{category.description && (
<p className="text-gray-600 mt-2">{category.description}</p>
)}
<p className="text-sm text-gray-500 mt-1">
{category.count} {category.count === 1 ? 'post' : 'posts'}
</p>
</header>
{/* Posts Grid */}
{posts.length === 0 ? (
<p className="text-gray-600">No posts found in this category.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<Link key={post.id} href={`/blog/${post.slug}`}>
<PostCard post={post} />
</Link>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<nav className="mt-12 flex justify-center gap-4">
{currentPage > 1 && (
<Link
href={`/category/${params.slug}?page=${currentPage - 1}`}
className="px-4 py-2 bg-gray-200 rounded"
>
← Previous
</Link>
)}
<span className="px-4 py-2">
Page {currentPage} of {totalPages}
</span>
{currentPage < totalPages && (
<Link
href={`/category/${params.slug}?page=${currentPage + 1}`}
className="px-4 py-2 bg-gray-200 rounded"
>
Next →
</Link>
)}
</nav>
)}
</main>
);
}Category List Component
Create a reusable component to display categories:
// src/components/CategoryList.tsx
import Link from 'next/link';
import { getCategories } from '@/lib/api';
interface CategoryListProps {
currentSlug?: string;
}
export async function CategoryList({ currentSlug }: CategoryListProps) {
const categories = await getCategories();
if (categories.length === 0) {
return null;
}
return (
<nav className="mb-8">
<h2 className="text-lg font-semibold mb-4">Categories</h2>
<ul className="flex flex-wrap gap-2">
<li>
<Link
href="/blog"
className={`px-3 py-1 rounded-full text-sm ${
!currentSlug
? 'bg-blue-600 text-white'
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
All
</Link>
</li>
{categories.map((category) => (
<li key={category.id}>
<Link
href={`/category/${category.slug}`}
className={`px-3 py-1 rounded-full text-sm ${
currentSlug === category.slug
? 'bg-blue-600 text-white'
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
{category.name} ({category.count})
</Link>
</li>
))}
</ul>
</nav>
);
}Adding Categories to Post Cards
Update PostCard to show categories:
// src/components/PostCard.tsx
import { WPPostWithEmbed, getFeaturedImageUrl } from '@/lib/types';
interface PostCardProps {
post: WPPostWithEmbed;
}
export function PostCard({ post }: PostCardProps) {
const imageUrl = getFeaturedImageUrl(post, 'medium_large');
// Get categories from embedded data
const categories = post._embedded?.['wp:term']?.[0] ?? [];
return (
<article className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
{imageUrl && (
<div className="aspect-video overflow-hidden">
<img
src={imageUrl}
alt={post.title.rendered}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-4">
{/* Categories */}
{categories.length > 0 && (
<div className="flex gap-2 mb-2">
{categories.slice(0, 2).map((cat) => (
<span
key={cat.id}
className="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded"
>
{cat.name}
</span>
))}
</div>
)}
<h2
className="text-xl font-bold mb-2"
dangerouslySetInnerHTML={{ __html: post.title.rendered }}
/>
<div
className="text-gray-600 text-sm line-clamp-2"
dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}
/>
</div>
</article>
);
}Using Categories in Blog Page
// src/app/blog/page.tsx
import { CategoryList } from '@/components/CategoryList';
export default async function BlogPage({ searchParams }: BlogPageProps) {
// ... existing code
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
{/* Add category filter */}
<CategoryList />
{/* Rest of the content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* ... posts */}
</div>
</main>
);
}Summary
In this lesson, we:
- ✅ Created category archive pages
- ✅ Built a reusable CategoryList component
- ✅ Added category badges to post cards
- ✅ Implemented active state for filtering
Next up: Optimizing images for performance.