Dynamic Routes for Posts
Understanding Dynamic Routes
In Next.js App Router, dynamic routes are created using folder names with square brackets: [slug]
For a blog, we want URLs like:
/blog/my-first-post/blog/hello-world
Let's build this!
Creating the Blog Route Structure
Create the following structure:
mkdir -p src/app/blog/[slug]
touch src/app/blog/[slug]/page.tsx
touch src/app/blog/page.tsxYour folder structure should now be:
src/app/
├── blog/
│ ├── page.tsx # /blog (listing)
│ └── [slug]/
│ └── page.tsx # /blog/:slug (single post)
├── layout.tsx
└── page.tsxBlog Listing Page
// src/app/blog/page.tsx
import { getPosts } from '@/lib/api';
import { PostCard } from '@/components/PostCard';
import Link from 'next/link';
export const metadata = {
title: 'Blog | My Headless WordPress Site',
description: 'Read our latest articles and insights.',
};
interface BlogPageProps {
searchParams: { page?: string };
}
export default async function BlogPage({ searchParams }: BlogPageProps) {
const currentPage = Number(searchParams.page) || 1;
const { posts, totalPages } = await getPosts({
perPage: 9,
page: currentPage
});
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
{posts.length === 0 ? (
<p className="text-gray-600">No posts found.</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 */}
<nav className="mt-12 flex justify-center gap-4">
{currentPage > 1 && (
<Link
href={`/blog?page=${currentPage - 1}`}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
← Previous
</Link>
)}
<span className="px-4 py-2">
Page {currentPage} of {totalPages}
</span>
{currentPage < totalPages && (
<Link
href={`/blog?page=${currentPage + 1}`}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Next →
</Link>
)}
</nav>
</>
)}
</main>
);
}Single Post Page
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPostBySlug, getAllPostSlugs } from '@/lib/api';
import { getFeaturedImageUrl, getAuthorName } from '@/lib/types';
import type { Metadata } from 'next';
interface PostPageProps {
params: { slug: string };
}
// Generate static params for all posts
export async function generateStaticParams() {
const slugs = await getAllPostSlugs();
return slugs.map((slug) => ({ slug }));
}
// Generate metadata for SEO
export async function generateMetadata({
params
}: PostPageProps): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
if (!post) {
return { title: 'Post Not Found' };
}
// Strip HTML tags for description
const description = post.excerpt.rendered
.replace(/<[^>]*>/g, '')
.substring(0, 160);
return {
title: `${post.title.rendered} | My Blog`,
description,
openGraph: {
title: post.title.rendered,
description,
type: 'article',
publishedTime: post.date,
modifiedTime: post.modified,
images: [getFeaturedImageUrl(post, 'large') || ''].filter(Boolean),
},
};
}
export default async function PostPage({ params }: PostPageProps) {
const post = await getPostBySlug(params.slug);
if (!post) {
notFound();
}
const featuredImage = getFeaturedImageUrl(post, 'large');
const author = getAuthorName(post);
// Format date
const publishDate = new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<article className="container mx-auto px-4 py-8 max-w-3xl">
{/* Header */}
<header className="mb-8">
<h1
className="text-4xl md:text-5xl font-bold mb-4"
dangerouslySetInnerHTML={{ __html: post.title.rendered }}
/>
<div className="flex items-center gap-4 text-gray-600">
<span>By {author}</span>
<span>•</span>
<time dateTime={post.date}>{publishDate}</time>
</div>
</header>
{/* Featured Image */}
{featuredImage && (
<div className="mb-8 rounded-lg overflow-hidden">
<img
src={featuredImage}
alt={post.title.rendered}
className="w-full h-auto"
/>
</div>
)}
{/* Content */}
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content.rendered }}
/>
{/* Post Footer */}
<footer className="mt-12 pt-8 border-t">
<a
href="/blog"
className="text-blue-600 hover:underline"
>
← Back to Blog
</a>
</footer>
</article>
);
}
💡 The
prose class: We're using Tailwind's Typography plugin for content styling. Install it with:
npm install @tailwindcss/typography
And add to tailwind.config.ts: plugins: [require('@tailwindcss/typography')]
Adding a 404 Page
Create a not-found page for missing posts:
// src/app/blog/[slug]/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-4xl font-bold mb-4">Post Not Found</h1>
<p className="text-gray-600 mb-8">
Sorry, the post you're looking for doesn't exist or has been removed.
</p>
<Link
href="/blog"
className="inline-block px-6 py-3 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Browse All Posts
</Link>
</div>
);
}Static vs Dynamic Rendering
With generateStaticParams, Next.js will:
- At build time: Pre-render all known post pages
- At runtime: Use ISR (Incremental Static Regeneration) for new posts
To enable ISR, add to your page:
// At the top of page.tsx
export const revalidate = 60; // Revalidate every 60 secondsTesting Your Routes
Start the dev server and test:
npm run devVisit:
http://localhost:3000/blog– Post listinghttp://localhost:3000/blog/your-post-slug– Single post
Summary
In this lesson, we:
- ✅ Created dynamic routes for blog posts
- ✅ Built a paginated blog listing
- ✅ Implemented single post pages with SEO metadata
- ✅ Added 404 handling
- ✅ Configured static generation
Next up: Adding category pages and filtering.