How I build a Next.js blog that stays fast at 100+ posts

By Nayan Kyada · · 7 min read

Part of Next.js Performance

A Next.js blog is easy to spin up and easy to let rot into a slow, bloated mess by post 80. The decisions that matter — static vs dynamic, how you paginate, where images come from, whether you reach for MDX or a CMS — compound over time. Here is how I structure projects so they stay fast as the archive grows.

Static generation strategy for a next.js blog

Every post page should be statically generated at build time. In the App Router that means exporting generateStaticParams from the [slug] segment. The function runs once at build and tells Next.js which slugs to render.

// app/blog/[slug]/page.tsx
import { getAllPostSlugs, getPostBySlug } from '@/lib/posts'
import type { Metadata } from 'next'
 
export async function generateStaticParams() {
  const slugs = await getAllPostSlugs() // returns { slug: string }[]
  return slugs
}
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await getPostBySlug(slug)
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: { images: [post.ogImage] },
  }
}
 
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPostBySlug(slug)
  return <article>{/* render post */}</article>
}

At 100 posts this build is still fast — generateStaticParams is a single data fetch, not one per post. The real trap is doing a per-slug fetch inside the component without caching. Use React's cache() or Next.js's fetch caching to deduplicate: the metadata call and the page call hit the same cached result.

For new posts published after the last deploy, add export const revalidate = 3600 to the segment. ISR will regenerate the page on the next request after the TTL expires without a full rebuild. If you want instant revalidation on publish, add a webhook that calls revalidateTag('posts') — covered in the Sanity Webhook Revalidation post on this site.

Pagination that doesn't kill build time

Do not statically generate every pagination page (/blog/page/2, /blog/page/3…). At 100 posts with 10 per page you have 10 pages — fine. At 500 posts you have 50 static pagination pages, most of which nobody visits. The tradeoff is worse than people think: build time grows, and stale paginated pages can show delisted posts.

My pattern: statically generate page 1 (the URL /blog) and render pages 2+ dynamically with dynamic = 'force-dynamic' or leave them un-parameterized and use search params.

// app/blog/page.tsx — page 1 is static
export const revalidate = 3600
 
import { getPosts } from '@/lib/posts'
import { PostCard } from '@/components/post-card'
import { Pagination } from '@/components/pagination'
 
export default async function BlogIndex({
  searchParams,
}: {
  searchParams: Promise<{ page?: string }>
}) {
  const { page } = await searchParams
  const currentPage = Number(page ?? '1')
  const { posts, total } = await getPosts({ page: currentPage, perPage: 12 })
 
  return (
    <main>
      <ul>
        {posts.map((p) => (
          <li key={p.slug}>
            <PostCard post={p} />
          </li>
        ))}
      </ul>
      <Pagination total={total} perPage={12} current={currentPage} />
    </main>
  )
}

Search-param-driven pagination means /blog?page=3 is a single route segment. Page 1 is cached by ISR; pages 2+ are dynamically rendered but still fast because the query is indexed on your CMS or database. The Pagination component renders standard <a> tags — no client-side JS router needed for basic navigation.

Image handling at scale

Images are where blogs visually degrade and Core Web Vitals suffer. Two rules I enforce on every project:

1. Always supply explicit width and height. Next.js <Image> with fill on a card grid works, but you must give the wrapper a fixed aspect ratio via CSS or CLS creeps in as the archive grows and editors upload odd-ratio covers.

2. Use sizes that match your actual layout. The default is 100vw, which makes Next.js generate a 3200 px image for a 380 px card. Fix it:

// components/post-card.tsx
import Image from 'next/image'
import type { Post } from '@/lib/types'
 
export function PostCard({ post }: { post: Post }) {
  return (
    <article className="group relative overflow-hidden rounded-xl">
      <div className="relative aspect-[16/9]">
        <Image
          src={post.coverImage}
          alt={post.coverAlt ?? post.title}
          fill
          sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
          className="object-cover transition-transform group-hover:scale-105"
          placeholder="blur"
          blurDataURL={post.lqip}
        />
      </div>
      <h2 className="mt-3 text-lg font-semibold">{post.title}</h2>
    </article>
  )
}

The blurDataURL field is a palette-derived placeholder — not a base64 JPEG. Generate it once when the image is uploaded (Sanity exposes a palette API; for local files run a build-time script with plaiceholder). This avoids shipping a 4 kB inline JPEG per card on a 12-post index page.

For the hero image on the post page, add priority to the above-the-fold image so the browser fetches it immediately rather than waiting for the <Image> to enter the viewport.

MDX vs CMS: where the line sits

For a developer-authored blog with git as the source of truth, MDX in /content/posts is the right call. No API latency, no build-time fetch, fully typed with a schema library like Zod, and images live in the repo or a CDN folder.

The problem is scale. At 20 posts a non-technical co-author breaks MDX within a week. At 50 posts you want draft previews, scheduled publishing, and image hotspots — none of which MDX handles without substantial custom tooling.

My rule: if anyone other than a developer will write or edit content, use a CMS from day one. Migrating MDX to Sanity at post 40 is painful — slugs change, image references need rewriting, and redirects accumulate. If it's a solo dev blog, MDX is fine indefinitely.

For the MDX path, compile posts at build time with next-mdx-remote/rsc and a rehype-pretty-code plugin for syntax highlighting. Avoid importing the full MDX runtime client-side — it's 45 kB+ that your readers never need.

Bundle discipline

A blog's JS bundle should be close to zero on the index and post pages. The content is static; there is no interactive state to hydrate. Three things routinely bloat it:

  • Syntax highlighter shipped as a client component. Use rehype-pretty-code (build time) or Shiki in an RSC. Never react-syntax-highlighter as a Client Component — it drags 120 kB into the bundle.
  • Date formatting libraries. date-fns is 30 kB if you import it naively. Use Intl.DateTimeFormat — it's built into V8, zero bytes.
  • Analytics scripts blocking render. Load them with <Script strategy="lazyOnload">. A blog post page should score 100 on Lighthouse Performance before you add any tracking.

Run @next/bundle-analyzer after every significant dependency addition, not just at the start of a project. Set a bundle size budget in CI — 50 kB for the blog route group is achievable and worth protecting.

Metadata and structured data

Every post needs a BlogPosting JSON-LD block. Add it to the post page layout as an RSC — no client JS, no library:

// app/blog/[slug]/page.tsx (inside the component)
const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.title,
  description: post.excerpt,
  datePublished: post.publishedAt,
  dateModified: post.updatedAt ?? post.publishedAt,
  author: { '@type': 'Person', name: post.author.name },
  image: post.ogImage,
}
 
return (
  <>
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
    <article>{/* post body */}</article>
  </>
)

This costs nothing at runtime — it's inlined into the static HTML — and it's the single highest-impact SEO addition you can make to a blog post beyond the title tag.

Keeping it fast long-term

The decisions that keep a Next.js blog fast at 100 posts are boring: static generation for every post, ISR for cache freshness, correct sizes on every image, no client-side JS where RSC will do, and a revalidateTag webhook so editors never wait for a full rebuild. None of this requires a complex architecture. It requires making the right choice once per concern and not revisiting it when the post count doubles.