DEV Community

Cover image for How I build a Next.js blog that stays fast at 100+ posts
Nayan Kyada
Nayan Kyada

Posted on Edited on Originally published at nayankyada.com

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

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>
}
Enter fullscreen mode Exit fullscreen mode

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', 'max') (Next.js 16 deprecates the single-argument form) — 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: don't pre-render a static route per pagination page; use a single /blog route that reads a page search param and render it on demand, with the underlying data fetch cached.

// app/blog/page.tsx — reads searchParams, so it renders per request
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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Search-param-driven pagination means /blog?page=3 is a single route segment. Reading searchParams opts the route into dynamic rendering at request time, so every page (including page 1) is rendered per request; keep it fast by caching the getPosts fetch (tagged so your revalidation webhook can invalidate it) and making sure the query is indexed on your CMS or database. If you need a fully static page 1, use path-based pagination with generateStaticParams for the first few pages instead. 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. Without sizes, the browser assumes the image is as wide as the viewport (100vw), so on a wide screen it downloads a near-full-width 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

The blurDataURL field is a tiny low-quality placeholder generated once, not at request time. Sanity generates one for every uploaded image (asset->metadata.lqip, a small base64 data URL you can query directly); for local files run a build-time script with plaiceholder. Keep these placeholders tiny so a 12-post index page doesn't inline kilobytes of image data per card.

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>
  </>
)
Enter fullscreen mode Exit fullscreen mode

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(tag, 'max') 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.

Top comments (0)