LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
Next.jsBeginner~2 hours

Static Blog with ISR

Build a blog using generateStaticParams and revalidate to combine static speed with fresh content.

SSGISRMetadata API

Overview

A blog is the clearest place to learn the App Router's rendering model, because it has one property almost every content-driven site shares: most visitors read a post long after it was published, so pre-rendering it once at build time is far cheaper than re-running the same database query on every single request. The catch is that a blog is never perfectly static either — a typo fix or a new post still needs to reach visitors without forcing a full rebuild and redeploy. Incremental Static Regeneration (ISR) is the App Router's answer to that gap: pages are built statically, served instantly from cache, and quietly refreshed in the background on a timer.

By the end of this tutorial you will have a /blog index and a /blog/[slug] dynamic route where every known post is pre-rendered at build time with generateStaticParams, kept fresh afterward with a revalidate export, and given its own per-post <title> and description through generateMetadata — the same three building blocks every real Next.js content site is built on, whether the data comes from a headless CMS, a database, or (as here, to keep the tutorial self-contained) an in-memory array standing in for one.

What You'll Build
  • A lib/posts.ts data layer with getAllPosts() and getPost(slug), shaped exactly like a real CMS/database call.
  • A generateStaticParams() function that pre-renders every known post slug into static HTML at build time.
  • A revalidate export that turns the static pages into ISR, refreshing content in the background on a timer.
  • A generateMetadata() function giving each post its own SEO <title>, description, and Open Graph tags.
  • A /blog index page listing every post, and a /blog/[slug] page rendering one post in full.
  • A notFound() call that returns a real 404 for a slug that does not match any post.

Prerequisites

  • Server vs Client Components — this entire project uses only Server Components, so understanding that they render on the server and never ship their own JavaScript to the browser matters before adding the 'use client' pieces in later projects.
  • The app/ directory convention — folders map to URL segments, page.tsx renders a segment, and [slug] denotes a dynamic segment.
  • async Server Components — this is your first project using them, so awaiting data directly inside a component body (no useEffect, no loading state) is introduced from scratch here.
  • Solid React fundamentals — components, props, and JSX, carried over directly from the React course.
  • Basic TypeScript — interfaces and typed function parameters, used throughout lib/posts.ts.

Project Structure

The data layer is isolated in lib/posts.ts so the route files never know or care whether posts come from an in-memory array, a headless CMS API, or a database query — every function in it is async and returns a Promise, matching the shape a real data source would have, so swapping the implementation later never touches a caller. app/blog/page.tsx renders the index; app/blog/[slug]/page.tsx is the dynamic route that does the actual pre-rendering, revalidation, and metadata work.

app/
blog/
page.tsx // Index listing every post — statically rendered, no dynamic segment
[slug]/
page.tsx // One post; exports generateStaticParams, revalidate, and generateMetadata
lib/
posts.ts // Data layer: getAllPosts(), getPost(slug) — stands in for a CMS/database

Step 1: Model the Post Data Source

Every function below is declared async even though the in-memory array behind it does not strictly need to be awaited. That is deliberate: it keeps the function signatures identical to what they would look like backed by a real fetch() to a CMS or a Prisma query, so a later swap to a real data source never requires changing anything that calls getAllPosts() or getPost().

lib/posts.ts
// lib/posts.ts
export interface Post {
slug: string;
title: string;
excerpt: string;
content: string;
publishedAt: string;
}
// A small in-memory list standing in for rows in a database table or entries
// from a headless CMS — the rest of this project only ever talks to it
// through the two functions below, never the array directly.
const POSTS: Post[] = [
{
slug: 'why-server-components',
title: 'Why Server Components Change How We Build UI',
excerpt: 'Server Components run only on the server, shrinking the JavaScript sent to the browser.',
content: 'Full article body for this post goes here...',
publishedAt: '2026-01-12',
},
{
slug: 'understanding-isr',
title: 'Understanding Incremental Static Regeneration',
excerpt: 'ISR lets a statically generated page update itself in the background, without a full rebuild.',
content: 'Full article body for this post goes here...',
publishedAt: '2026-02-03',
},
{
slug: 'app-router-data-fetching',
title: 'Data Fetching Patterns in the App Router',
excerpt: 'async Server Components let you fetch data with a plain await, no useEffect required.',
content: 'Full article body for this post goes here...',
publishedAt: '2026-02-20',
},
];
export async function getAllPosts(): Promise<Post[]> {
return POSTS;
}
export async function getPost(slug: string): Promise<Post | undefined> {
return POSTS.find((post) => post.slug === slug);
}

Step 2: Pre-render Known Posts with generateStaticParams

generateStaticParams runs once at build time, not per request — it is the App Router's replacement for the old Pages Router's getStaticPaths. Whatever array of objects it returns becomes a list of routes Next.js pre-renders into static HTML files up front, so the very first visitor to hit /blog/understanding-isr gets a page that was already built, not one generated on the spot.

app/blog/[slug]/page.tsx (part 1)
// app/blog/[slug]/page.tsx
import { getAllPosts } from '@/lib/posts';
// Runs at BUILD time. Each returned object's keys must match the dynamic
// segment's folder name exactly — [slug] here, so the key has to be "slug" —
// or Next.js has no way to connect a returned value to this route.
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}

Step 3: Enable Background Updates with revalidate

A page that only used generateStaticParams would be pure static generation (SSG) — fast, but frozen at whatever it looked like the moment it was built. Adding a revalidate export turns that same static page into ISR: Next.js still serves the pre-built HTML instantly on every request, but once more than revalidate seconds have passed since the last build, the *next* request triggers a regeneration in the background. That visitor (and everyone else) still gets the fast cached page immediately; only after the rebuild finishes does traffic start seeing the refreshed content.

app/blog/[slug]/page.tsx (part 2)
// 60 seconds is short on purpose, so a change feels snappy while working
// through this tutorial. A real blog, where posts rarely change once
// published, would more commonly set this to something like 3600 (1 hour)
// or even 86400 (1 day) — the right number trades off "how stale can this
// content tolerably be" against "how much regeneration work is worth paying for".
export const revalidate = 60;
ISR vs. a Fully Dynamic Page

A route with no revalidate export and no dynamic data access renders once at build time and never again until the next deploy — pure SSG. Calling something like cookies() or passing { cache: 'no-store' } to fetch() instead makes the route fully dynamic, rendering fresh on every single request. revalidate sits deliberately between those two: static speed for every visitor, with content that is never more than revalidate seconds out of date.

Step 4: Generate Per-Post SEO Metadata

generateMetadata runs alongside the page component and lets each post ship its own <title> and description, rather than every post under /blog/[slug] sharing one static value inherited from a parent layout. That matters specifically for a blog, where each post is competing for its own snippet in search results and its own preview card when a link is shared.

app/blog/[slug]/page.tsx (part 3)
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
import { getPost } from '@/lib/posts';
interface Props {
params: { slug: string };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) {
// A missing post still needs SOME metadata returned — the page
// component itself handles the actual 404 response in Step 5.
return { title: 'Post Not Found' };
}
return {
title: post.title + ' | DevBlog',
description: post.excerpt,
// openGraph controls how the link looks when shared on social platforms
// or pasted into Slack/Discord — worth setting explicitly for content
// that is meant to be shared, which a blog post usually is.
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
},
};
}

Step 5: Build the Post Page and Blog Index

The page component awaits getPost directly in its own body — no useEffect, no separate loading state, because this component only ever executes on the server before any HTML reaches the browser. notFound() is what turns a missing slug into a real 404 response instead of a page that silently renders nothing; it stops the current render and hands off to the nearest not-found.tsx.

app/blog/[slug]/page.tsx (part 4)
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/posts';
export default async function BlogPostPage({ params }: Props) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<p className="published-date">Published {post.publishedAt}</p>
<div className="post-body">{post.content}</div>
</article>
);
}

The index page needs none of the three exports above — it has no dynamic segment, so Next.js statically renders it once at build time automatically, with no extra configuration required.

app/blog/page.tsx
// app/blog/page.tsx
import Link from 'next/link';
import { getAllPosts } from '@/lib/posts';
export default async function BlogIndexPage() {
const posts = await getAllPosts();
return (
<div className="blog-index">
<h1>The DevBlog</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
{/* Building the href with concatenation instead of a template
literal here is purely a style choice — either works — but it
keeps this file's syntax consistent with the string-building
used throughout the rest of this project's Server Actions. */}
<Link href={'/blog/' + post.slug}>
<h2>{post.title}</h2>
</Link>
<p>{post.excerpt}</p>
</li>
))}
</ul>
</div>
);
}

Complete Code

Here is the complete project — the data layer and both route files, fully assembled from the steps above.

lib/posts.ts
export interface Post {
slug: string;
title: string;
excerpt: string;
content: string;
publishedAt: string;
}
const POSTS: Post[] = [
{
slug: 'why-server-components',
title: 'Why Server Components Change How We Build UI',
excerpt: 'Server Components run only on the server, shrinking the JavaScript sent to the browser.',
content: 'Full article body for this post goes here...',
publishedAt: '2026-01-12',
},
{
slug: 'understanding-isr',
title: 'Understanding Incremental Static Regeneration',
excerpt: 'ISR lets a statically generated page update itself in the background, without a full rebuild.',
content: 'Full article body for this post goes here...',
publishedAt: '2026-02-03',
},
{
slug: 'app-router-data-fetching',
title: 'Data Fetching Patterns in the App Router',
excerpt: 'async Server Components let you fetch data with a plain await, no useEffect required.',
content: 'Full article body for this post goes here...',
publishedAt: '2026-02-20',
},
];
export async function getAllPosts(): Promise<Post[]> {
return POSTS;
}
export async function getPost(slug: string): Promise<Post | undefined> {
return POSTS.find((post) => post.slug === slug);
}
app/blog/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getAllPosts, getPost } from '@/lib/posts';
interface Props {
params: { slug: string };
}
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export const revalidate = 60;
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) {
return { title: 'Post Not Found' };
}
return {
title: post.title + ' | DevBlog',
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
},
};
}
export default async function BlogPostPage({ params }: Props) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<p className="published-date">Published {post.publishedAt}</p>
<div className="post-body">{post.content}</div>
</article>
);
}
app/blog/page.tsx
import Link from 'next/link';
import { getAllPosts } from '@/lib/posts';
export default async function BlogIndexPage() {
const posts = await getAllPosts();
return (
<div className="blog-index">
<h1>The DevBlog</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={'/blog/' + post.slug}>
<h2>{post.title}</h2>
</Link>
<p>{post.excerpt}</p>
</li>
))}
</ul>
</div>
);
}

Sample Run

Build Time vs. Request Time

Click Run to see what this code prints.

Extend This Project

  • Add tags or categories to each post and a /blog/tag/[tag] route with its own generateStaticParams.
  • Generate sitemap.xml and robots.txt with Next.js's built-in app/sitemap.ts and app/robots.ts conventions, driven by the same getAllPosts().
  • Add pagination to the index page using a ?page= search param instead of listing every post at once.
  • Swap lib/posts.ts for a real headless CMS (Contentful, Sanity) or MDX files read from disk — no other file should need to change.
  • Add a draft-preview mode with next/headers' draftMode() so an unpublished post can be viewed before it goes live.

Summary

You built a blog that pre-renders every known post at build time with generateStaticParams, stays fresh afterward with a revalidate window instead of a full redeploy, and gives each post its own SEO metadata with generateMetadata. That combination — static by default, updated on a schedule, personalized per route where it matters — is the rendering strategy behind most content-driven Next.js sites you will build or work on next.