LESSON 5 ⏱️ 10 min read

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.tsx

Your folder structure should now be:

src/app/
├── blog/
│   ├── page.tsx          # /blog (listing)
│   └── [slug]/
│       └── page.tsx      # /blog/:slug (single post)
├── layout.tsx
└── page.tsx

Blog 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:

  1. At build time: Pre-render all known post pages
  2. 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 seconds

Testing Your Routes

Start the dev server and test:

npm run dev

Visit:

  • http://localhost:3000/blog – Post listing
  • http://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.