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.
- 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 generateMetadatalib/ posts.ts // Data layer: getAllPosts(), getPost(slug) — stands in for a CMS/databaseStep 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.tsexport 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.tsximport { 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.
// 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;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.tsximport { 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.tsximport { 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.tsximport 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.
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);}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> );}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
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.