LearnAI ToolsCareerPractice BuildsPlayContact
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.