Build Phases

Phase 3 — Dynamic Pages

Blog listing, individual posts, and the hero news banner.

Blog Listing Page

// src/app/blog/page.tsx
import { getAllPosts, getFeaturedImageUrl, getFeaturedImageAlt } from "@/lib/wordpress";
import Image from "next/image";
import Link from "next/link";

export const metadata = { title: "Blog | Your Site Name" };

export default async function BlogPage() {
  const posts = await getAllPosts(12);
  return (
    <main>
      <h1>Blog</h1>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
        {posts.map((post) => (
          <article key={post.id}>
            <Link href={`/blog/${post.slug}`}>
              <Image
                src={getFeaturedImageUrl(post)}
                alt={getFeaturedImageAlt(post)}
                width={600} height={400} loading="lazy"
              />
              <h2 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
              <div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
              <time dateTime={post.date}>
                {new Date(post.date).toLocaleDateString("en-US", {
                  year:"numeric", month:"long", day:"numeric"
                })}
              </time>
            </Link>
          </article>
        ))}
      </div>
    </main>
  );
}

Dynamic Blog Post Page

// src/app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPosts, getFeaturedImageUrl, getFeaturedImageAlt } from "@/lib/wordpress";
import { notFound } from "next/navigation";
import Image from "next/image";

// Pre-generate the 20 most recent posts at build time
export async function generateStaticParams() {
  const posts = await getAllPosts(20);
  return posts.map((post) => ({ slug: post.slug }));
}
export const dynamicParams = true; // Other pages generate on-demand

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  if (!post) return { title: "Post Not Found" };
  return {
    title: post.title.rendered,
    description: post.excerpt.rendered.replace(/<[^>]+>/g, "").slice(0, 160),
    openGraph: { images: [getFeaturedImageUrl(post, "full")] },
  };
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  if (!post) notFound();
  return (
    <article>
      <h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
      <Image src={getFeaturedImageUrl(post,"full")} alt={getFeaturedImageAlt(post)}
        width={1200} height={630} priority />
      {/* Use prose class from @tailwindcss/typography to style WP content */}
      <div className="prose prose-lg max-w-none"
        dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
    </article>
  );
}
TIP

WordPress content is raw HTML. Add className="prose prose-lg max-w-none" from @tailwindcss/typography to automatically style headings, paragraphs, links, images, and lists inside WordPress content.

Hero News Banner

// src/components/HeroNewsBanner.tsx
import { getLatestPost } from "@/lib/wordpress";
import Link from "next/link";

export default async function HeroNewsBanner() {
  const post = await getLatestPost();
  if (!post) return null;
  return (
    <div className="bg-primary text-white py-2 px-4 text-sm">
      <span className="font-semibold mr-2">Latest:</span>
      <Link href={`/blog/${post.slug}`} className="underline hover:no-underline"
        dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
    </div>
  );
}

Key Concepts

ConceptWhat it does
generateStaticParamsPre-renders the N most recent pages at build time for instant load
dynamicParams = trueAny slug not in static params generates on first request then caches
generateMetadataPer-page SEO title, description, and Open Graph image from WordPress
dangerouslySetInnerHTMLRenders raw WordPress HTML — always pair with the prose class
PreviousPhase 2 — API LayerNextPhase 4 — Forms