ISR in Next.js: static pages that still update when you edit
ISR in Next.js for a blog, measured: 162 ms and 11 database queries versus 6 ms and none. See how pages refresh by themselves and right after you edit a post.
What is ISR in Next.js? It builds a page ahead of time like a static page, then rebuilds it after a set time or when you ask for a refresh. Readers get a ready-made page in a few milliseconds, and the database is only asked when the page is rebuilt. Want a blog that is fast and still editable? Let's measure it.
Why does every view ask the database?
In the previous post, blog-mini, the small blog project this series builds step by step, let the author log in and write posts through an API while strangers couldn't. At the end I mentioned something that wasn't right on the reader's side. The post page calls Supabase on every view. A published post almost never changes, yet a thousand readers mean a thousand identical queries, and the free plan has limits.
To measure this without a Supabase account, I built a minimal Next.js 16.4.4 app. The "database" is a JSON file, but the function that reads it waits 150 ms to feel like a network trip and prints a [db] line every time it's called. With that log line you can count how many times a page really asked for data.
Here are the files you need. To keep the example short I wrote it in JavaScript instead of TypeScript. Create an empty folder, put these files at the paths shown, then run npm install:
{
"name": "blog-mini-06-isr",
"private": true,
"scripts": {
"build": "next build",
"start": "next start -p 3100"
},
"dependencies": {
"next": "16.4.4",
"react": "19.3.1",
"react-dom": "19.3.1"
}
}[
{ "slug": "hello-world", "title": "Hello, world", "body": "This is my first post." },
{ "slug": "why-i-write-a-blog", "title": "Why I write a blog", "body": "Writing helps me understand things better." }
]import { readFileSync } from "node:fs";
import { join } from "node:path";
let queryCount = 0;
const readFile = () => JSON.parse(readFileSync(join(process.cwd(), "data", "posts.json"), "utf8"));
// Stands in for a Supabase query: reads the file, waits 150 ms to feel like a network trip, and counts how often it is called.
// Every call prints one line to the server's terminal.
export async function getPost(slug) {
queryCount += 1;
console.log(`[db] query #${queryCount}: post "${slug}"`);
await new Promise((done) => setTimeout(done, 150));
return readFile().find((post) => post.slug === slug) ?? null;
}
export async function getAllSlugs() {
return readFile().map((post) => post.slug);
}export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}Last comes the post page. The function generateStaticParams tells Next.js which slug values exist, so it can build each page ahead of time during the build:
import { notFound } from "next/navigation";
import { getAllSlugs, getPost } from "../../../lib/posts.js";
export async function generateStaticParams() {
return (await getAllSlugs()).map((slug) => ({ slug }));
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<main>
<h1>{post.title}</h1>
<p>{post.body}</p>
</main>
);
}I use a small function in the terminal so that each request prints the x-nextjs-cache header and the post's title. Use Git Bash on Windows, or the terminal on macOS and Linux:
check() { curl -s -D /tmp/h.txt localhost:3100/posts/hello-world -o /tmp/b.html
echo " $(grep -io '^x-nextjs-cache: .*' /tmp/h.txt | tr -d '\r') | $(grep -o '<h1>[^<]*' /tmp/b.html | sed 's/<h1>//')"; }Next.js adds the x-nextjs-cache header by itself when you run next start. It has three values that this post will show: HIT means the response came from the cache, STALE means a cached copy that has expired was served, and MISS means the page had to be built fresh.
What happens with only generateStaticParams?
I build and run the files above straight away, without a single line about caching:
[db] query #1: post "hello-world"
[db] query #2: post "why-i-write-a-blog"
Route (app)
┌ ○ /_not-found
└ /posts/[slug]
├ ● /posts/hello-world
└ ● /posts/why-i-write-a-blog
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)The two database queries happen at build time, one per post, and the ● symbol means both pages were built as static HTML. I ran npm start, sent five requests, then changed the title in data/posts.json to "Hello, edited", waited 11 seconds and sent five more:
First 5 requests:
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
Edited the title in data/posts.json. Waiting 11 seconds...
Next 5 requests:
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, worldThe server log has no [db] line at all for these requests. The page is fast, but it is frozen at the moment of the build. To see my edit, I'd have to build and deploy the whole blog again. That's not acceptable for a blog whose author fixes typos every day.
That's why I think many people first pick the opposite approach, letting the page call the database on every request. I try that next.
What do you lose when the page is rebuilt on every request?
The line export const dynamic = "force-dynamic" forces Next.js to rebuild the page on every request. It reproduces exactly the current state of blog-mini, where the page queries the database on every view. I added that one line to the page, rebuilt and ran the same script: five requests, edit the data file, wait 11 seconds, then more requests.
First 5 requests:
| Hello, world
| Hello, world
| Hello, world
| Hello, world
| Hello, world
Edited the title in data/posts.json. Waiting 11 seconds...
Next 3 requests:
| Hello, edited
| Hello, edited
| Hello, edited
Two seconds later:
| Hello, edited
| Hello, edited
Server log:
[db] query #1: post "hello-world"
[db] query #2: post "hello-world"
[db] query #3: post "hello-world"
[db] query #4: post "hello-world"
[db] query #5: post "hello-world"
[db] query #6: post "hello-world"
[db] query #7: post "hello-world"
[db] query #8: post "hello-world"
[db] query #9: post "hello-world"
[db] query #10: post "hello-world"The edit shows up on the very next request, and there's no x-nextjs-cache header because nothing is cached. Ten requests mean ten database queries. I also timed 10 requests in a row, after one warm-up request:
fastest 161 ms, slowest 177 ms, median 162 ms
database queries: 11 (11 requests)The 162 ms is almost exactly the 150 ms I deliberately wait inside getPost, which means the whole response time of the page is the time spent waiting for the database. On your machine and with real Supabase the numbers will differ, but the relationship is the same: every view pays for exactly one more query.
How does ISR give you both?
ISR stands for incremental static regeneration: rebuilding a static page a piece at a time, one page at a time, when needed. In Next.js it's a single line. I added it to the post page:
// ISR: the page is cached, and at most once every 10 seconds Next.js rebuilds it in the background.
export const revalidate = 10;I chose 10 seconds to make this post quick to measure. For a real blog I'd pick something larger, and the end of this post explains why. The Next.js guide to ISR describes how it works. Until the time runs out, every request gets the stored page. After it runs out, the next request still gets the old copy, but it triggers a rebuild in the background. Once the new version is ready, later requests get it.
I ran the same script again:
First 5 requests:
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
x-nextjs-cache: HIT | Hello, world
Edited the title in data/posts.json. Waiting 11 seconds...
Next 3 requests:
x-nextjs-cache: STALE | Hello, world
x-nextjs-cache: STALE | Hello, world
x-nextjs-cache: STALE | Hello, world
Two seconds later:
x-nextjs-cache: HIT | Hello, edited
x-nextjs-cache: HIT | Hello, edited
Server log:
[db] query #1: post "hello-world"Three things are worth a close look. The first five requests are all HIT, and the log has no [db] line for them. After I edited the data and waited more than 10 seconds, the next three requests were STALE and still returned "Hello, world", the old title, because the old copy keeps being served while the new one is being built. And that whole stretch produced only one [db] line, the background rebuild. Two seconds later the page was the new version and went back to HIT.
With this approach, readers never wait for the database, and the first visitor after the expiry still gets a page immediately, even if it's a few seconds out of date. Timing it the same way as version 2:
fastest 5 ms, slowest 8 ms, median 6 ms
database queries: 0 (11 requests)From 162 ms down to 6 ms, and from 11 database queries down to none. I don't want to oversell the 6 ms, because this server runs on my own machine with no network in between. On a real server, readers also pay network latency. What you can trust here is the difference between the two versions on the same machine, and the fact that the database is no longer being asked.
How do you update a post immediately after editing it?
One thing still isn't right. The author fixes a typo, hits save, opens the post and still sees the old text for up to 10 seconds, or longer if revalidate is large. The fix is to allow an immediate refresh when something changes. Next.js's revalidatePath function marks the cache of a path as expired.
In Tech Notes, the blog you're reading, saving a post calls a small helper that ends in one line like this (the real code is in src/lib/admin/revalidation.ts, and the save actions in src/app/admin/actions.ts call it):
// Published content appears across many pages (home, lists, tags, feeds), so refresh them all.
revalidatePath("/", "layout");The real helper only runs this line when the change can alter a public page, so saving a draft doesn't throw away the cache. In Tech Notes a post appears on many pages, so I refresh the whole tree from the root. In this demo only one page needs refreshing, so I wrote a small Route Handler that can be called from outside:
import { revalidatePath } from "next/cache";
// Called after the author edits a post: drops the cache of exactly that page, so the next view rebuilds it right away.
// Only someone who knows REVALIDATE_SECRET can call it. If the variable isn't set, every request is rejected.
export async function POST(request) {
const secret = process.env.REVALIDATE_SECRET;
if (!secret || request.headers.get("x-revalidate-secret") !== secret) {
return Response.json({ error: "not authorized" }, { status: 401 });
}
const { slug } = await request.json();
revalidatePath(`/posts/${slug}`);
return Response.json({ revalidated: `/posts/${slug}` });
}The secret check is mandatory. Without it, anyone who knows the address /api/revalidate could call it thousands of times, forcing the server to rebuild pages and query the database constantly, exactly what ISR exists to avoid. The secret lives in an environment variable and is never written into the code.
I started the server with REVALIDATE_SECRET=test-secret npm start, edited the title, then tried the call:
Before the edit:
x-nextjs-cache: HIT | Hello, world
Edited the file. Looking right away (no revalidate call yet):
x-nextjs-cache: HIT | Hello, world
Calling /api/revalidate without the secret:
HTTP 401
Calling /api/revalidate with the secret:
{"revalidated":"/posts/hello-world"}
Looking again:
x-nextjs-cache: MISS | Hello, edited
x-nextjs-cache: HIT | Hello, edited
Server log:
[db] query #1: post "hello-world"After editing the file without calling the refresh, the page is still the old version, as expected. A call without the secret is rejected with 401. A call with the secret makes the next request a MISS, because the cache was dropped and the page had to be rebuilt, and it returns the new title right away. In total there was only one database query.
The MISS on the first request after the refresh matches the documentation of revalidatePath. In a Route Handler, this function only marks the path as expired, and the rebuild happens on the next visit. That visit, usually the author's own, pays for one build.
How many seconds should revalidate be?
If you already refresh immediately when a post is edited, revalidate is only a safety net for changes that don't go through the save button, like a scheduled post or data edited directly in the dashboard. I don't think the number needs to be small. One hour (3600) is reasonable for a blog, and Tech Notes uses that value for rss.xml. With a number as small as 10 seconds, the database is asked more often than needed, and readers can't tell the difference.
Scheduled posts bring one more caveat. If a post has a published_at in the future and the list page is only rebuilt when someone calls the refresh, the post won't appear at the scheduled time. One fix is to make revalidate short enough on list pages. Another is a scheduled job that calls the refresh at the right time. This post doesn't do either, I'm only noting it so you know it's a trade-off of ISR.
Remember too that ISR needs a running Node.js server, such as next start or Vercel. If you export the blog as fully static HTML files and put them on a file host, there's nothing to rebuild in the background, and ISR isn't available.
How do you write posts from now on?
The blog is now fast and still editable. But looking back over the whole series, the author still writes posts by typing directly into the posts table, or by editing in the Supabase dashboard. That's tolerable for two posts and torture for twenty, each with a title, description, tags and long Markdown content. I need a place to write, preview the result, and save straight to the database.
Writing Markdown with an instant preview, while keeping other people from injecting malicious code into the page, is the job of later posts.
You can follow the whole series at Building a blog with Next.js + Supabase. If you're preparing for interviews, questions about Next.js and databases are in the interview question bank.