Incremental Static Regeneration (ISR) works on a self-hosted Next.js server without any extra setup. Pages and fetched data are cached on the server's disk and in memory, and are regenerated in the background when their revalidate time passes or when you call revalidatePath or revalidateTag. The part that needs thought is scale: with one server instance everything just works, but with several instances each one has its own cache unless you configure a shared cache handler. This guide covers both cases, with code for Next.js 15 and 16.
How ISR works in one paragraph
A page is rendered once (at build time or on the first request) and the HTML and data are stored in the cache. Visitors get the cached version instantly. When the page becomes stale, the next visitor still gets the cached version while Next.js regenerates it in the background (stale-while-revalidate). Once regeneration succeeds, later visitors get the fresh page. If regeneration fails, the old version keeps being served.
Time-based revalidation
Set a revalidation period for a whole route:
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // regenerate at most once an hour
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await fetch(`https://cms.example.co.za/posts/${slug}`, {
next: { revalidate: 3600, tags: ["posts", `post:${slug}`] },
}).then((r) => r.json());
return <article><h1>{post.title}</h1></article>;
}
Note that params is a Promise in Next.js 15 and 16, so you await it.
The fetch options do two things: revalidate sets how long this data may be cached, and tags labels it so you can invalidate it on demand later.
On-demand revalidation
Time-based revalidation means content can be up to an hour stale. For a CMS or a product catalogue, you usually want updates to appear as soon as an editor clicks publish. Use on-demand revalidation:
revalidatePath("/blog")marks a specific path as stale.revalidateTag("posts", "max")marks all data taggedpostsas stale. In Next.js 16, the second argument (a cache life profile) is required, and"max"gives stale-while-revalidate behaviour. In Next.js 15 you callrevalidateTag("posts")with one argument.updateTag("posts")(Next.js 16, Server Actions only) expires the tag immediately, so the user who made the change sees it on their next render. Use it after a form submission.
A typical CMS webhook endpoint:
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import type { NextRequest } from "next/server";
const ALLOWED_TAGS = new Set(["posts", "products", "settings"]);
export async function POST(request: NextRequest) {
if (request.headers.get("x-revalidate-secret") !== process.env.REVALIDATE_SECRET) {
return Response.json({ ok: false }, { status: 401 });
}
const { tag } = (await request.json()) as { tag?: string };
if (!tag || !ALLOWED_TAGS.has(tag)) {
return Response.json({ ok: false, error: "unknown tag" }, { status: 400 });
}
revalidateTag(tag, "max");
return Response.json({ ok: true, tag });
}
Point your CMS webhook at https://yourdomain.co.za/api/revalidate with the secret header. Keep the secret in an environment variable, and never expose it in client code. For more on verifying incoming requests, see webhooks explained.
Where the cache lives when you self-host
With next start or standalone output, Next.js uses:
- An in-memory cache for recently used entries (50 MB by default, set with
cacheMaxMemorySize). - The file system under
.next/cache, plus prerendered pages from the build.
On a single server this is all you need. Revalidation calls update the only cache there is, and every visitor sees the result.
Two practical points:
- Deploys reset runtime-generated pages. A new build produces a new set of prerendered pages. Pages regenerated at runtime by the previous build are not carried over, which is usually what you want, because your code may have changed.
- Memory counts. A large in-memory cache on a small plan competes with your app for RAM. If memory is tight, lower
cacheMaxMemorySize.
Running more than one instance
Once you run several instances behind a load balancer, each has its own memory and disk cache. Call revalidateTag on one, and the others keep serving stale content until their own timers expire. Visitors may see new content on one request and old content on the next.
The fix is a shared cache handler, typically backed by Redis:
// next.config.js
module.exports = {
cacheHandler: require.resolve("./cache-handler.js"),
cacheMaxMemorySize: 0, // disable the per-instance memory cache
};
Your cache-handler.js implements get, set and revalidateTag against the shared store. Community packages provide ready-made Redis handlers; the Next.js ISR docs describe the interface.
Also make sure all instances:
- run the same build, so build IDs match (set
generateBuildIdif you build separately per instance) - share the same
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, so Server Actions work regardless of which instance handles the request
Cache Components in Next.js 16
Next.js 16 introduced Cache Components (enabled with cacheComponents: true), built around the "use cache" directive with cacheLife and cacheTag instead of route-level revalidate exports:
import { cacheLife, cacheTag } from "next/cache";
async function getPosts() {
"use cache";
cacheLife("hours");
cacheTag("posts");
return fetch("https://cms.example.co.za/posts").then((r) => r.json());
}
revalidateTag and updateTag work with these tags the same way. For multi-instance setups, Cache Components use the separate cacheHandlers (plural) config for shared storage. If you are starting a new app on Next.js 16, this model is worth learning; existing apps using revalidate and fetch options keep working without it.
Choosing a setup
| Situation | Recommended setup |
|---|---|
| One instance (most small and medium sites) | Default file system cache, on-demand revalidation from your CMS |
| Several instances | Shared cache handler (Redis), same build and encryption key everywhere |
| Content changes rarely | Longer revalidate, rely on on-demand revalidation for edits |
| Per-user content | Don't cache it; render dynamically |
On NewHost
Next.js apps on NewHost run as a regular Node.js server, so ISR, revalidatePath and revalidateTag work as described above for a single instance, with no extra setup. For CMS-driven sites, pair an on-demand revalidation route with your CMS's publish webhook. If you prefer scheduled refreshes, a scheduled task can run a small script that calls your revalidation endpoint, as often as every 5 minutes.
Frequently asked questions
Does ISR work without Vercel?
Yes. ISR is part of Next.js and works on any Node.js server running next start or standalone output. The cache is stored on the server's disk and in memory.
Why isn't revalidateTag updating my page?
Check that the data was fetched with that tag, that the webhook reached the same instance that serves your traffic, and in Next.js 16 that you passed a cache life profile as the second argument. With multiple instances, you need a shared cache handler.
What is the difference between revalidatePath and revalidateTag?
revalidatePath invalidates a route by its URL. revalidateTag invalidates all cached data with a given tag, wherever it is used, which is better when one piece of content appears on several pages.
Is ISR the same as static export?
No. output: 'export' produces fixed HTML files with no server, so there is no revalidation. ISR needs a running Next.js server to regenerate pages.
Running a CMS-driven Next.js site? NewHost Next.js hosting gives you a Node.js server in Johannesburg where ISR works out of the box. See also self-hosting Next.js vs Vercel.