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. Neverreact-syntax-highlighteras a Client Component — it drags 120 kB into the bundle. -
Date formatting libraries.
date-fnsis 30 kB if you import it naively. UseIntl.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.
Top comments (0)