Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
Caching in the Next.js App Router, explained with real pages How we choose between time-based revalidation, on-demand tags, dynamic rendering and static params for listing, article and dashboard pages.
An editor publishes a correction to a tool review. Ten minutes later they open the page and the old text is still there. They publish again. Still the old text. Meanwhile the dashboard in the same app shows another user's saved filters for a moment after login, because a page that should never have been cached was.
Both problems come from the same place: the Next.js App Router makes caching decisions per route and per request, and if you do not state your intent, you get whatever the framework infers. Those inferences and defaults have changed between major versions, so we no longer rely on them. On TechLogHub and on a service business CRM we build, every data fetch says explicitly how fresh it needs to be. This post walks through the pages we have and the choice we made for each.
Three questions for every page Before touching any cache option we ask three things about a page:
Is the content the same for every visitor, or does it depend on who is asking? How stale can it be before someone notices or cares? Do we know the moment it changes, for example when an editor clicks publish? The answers map almost directly onto the tools the App Router gives you: time-based revalidation, on-demand revalidation by path or tag, fully dynamic rendering, and static generation of known paths at build time.
One general note before the examples. The behaviour of fetch when you pass no caching options, and some of the route-level defaults, differ between Next.js versions, and newer versions also offer an opt-in model built around a use cache directive. Check the caching section of the docs for the version you are on. Everything below sets options explicitly so that it reads the same regardless of defaults.
A listing page: time-based revalidation Our category listing pages show tools in a category, sorted by popularity. They are the same for everyone and nobody minds if the order is an hour old. That is a good fit for time-based revalidation.
// app/categories/[slug]/page.tsx
import { ToolList } from "@/components/tool-list";
type Props = { params: Promise<{ slug: string }> };
async function getTools(slug: string) {
const res = await fetch(`${process.env.API_URL}/categories/${slug}/tools`, {
next: { revalidate: 3600, tags: [`category:${slug}`] },
});
if (!res.ok) throw new Error("Failed to load tools");
return res.json();
}
export default async function CategoryPage({ params }: Props) {
const { slug } = await params;
const tools = await getTools(slug);
return <ToolList tools={tools} />;
}With revalidate set to 3600, the cached response is reused for up to an hour. After that, the next request still gets the cached version while Next.js fetches fresh data in the background, and later requests get the new result. It is stale-while-revalidate, so the first visitor after the hour does not wait.
You can also set this for a whole route segment with export const revalidate = 3600 in the page or layout file. We prefer per-fetch options when a page combines data with different freshness needs, and the segment setting when the whole page shares one rule. Note that in recent versions params is passed as a promise, which is why the example awaits it; older versions passed a plain object.
An article page: static paths and on-demand revalidation Tool reviews are the opposite case. They change rarely, but when they do, the editor expects the change to appear straight away. Waiting up to an hour is the bug from the opening paragraph.
We generate the most visited review pages at build time with generateStaticParams, and render the rest on first request. Each fetch carries a tag that identifies the review.
// app/reviews/[slug]/page.tsx
export async function generateStaticParams() {
const res = await fetch(`${process.env.API_URL}/reviews/popular`, {
next: { revalidate: 86400 },
});
const reviews: { slug: string }[] = await res.json();
return reviews.map((r) => ({ slug: r.slug }));
}
async function getReview(slug: string) {
const res = await fetch(`${process.env.API_URL}/reviews/${slug}`, {
next: { revalidate: 86400, tags: [`review:${slug}`] },
});
if (res.status === 404) return null;
return res.json();
}If you want unknown slugs to return a 404 instead of being rendered on demand, export const dynamicParams = false from the same file. We leave it on, because new reviews are published between builds.
The long revalidate value is a safety net. The real update path is a webhook from the CMS that calls a route handler when an editor publishes:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
if (req.headers.get("x-revalidate-secret") !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const { slug, category } = await req.json();
revalidateTag(`review:${slug}`);
if (category) revalidateTag(`category:${category}`);
revalidatePath("/"); // the home page lists recent reviews
return NextResponse.json({ ok: true });
}revalidateTag marks every cached fetch with that tag as stale, wherever it is used. revalidatePath does the same for a specific route. Tags are more precise because one review can appear on its own page, a category page and the home page, and a tag catches all of them without you listing the paths. Recent versions have refined the revalidateTag signature, so check whether yours expects a second argument. Both functions can also be called from a server action, which is how we handle edits made inside the app itself.
A dashboard: dynamic rendering The CRM dashboard shows each user their own pipeline. It must never be shared between users, and it must always be current. This page should render on every request.
Next.js switches a route to dynamic rendering when it uses request-specific information. The common triggers are reading cookies or headers, using searchParams in a page, and fetches marked cache: no-store. Reading the session cookie is enough on its own, but we also mark the data fetch explicitly so nobody later removes the cookie read and accidentally makes the page static.
// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { Pipeline } from "@/components/pipeline";
export default async function Dashboard() {
const cookieStore = await cookies();
const session = cookieStore.get("session")?.value;
const res = await fetch(`${process.env.API_URL}/me/pipeline`, {
headers: { Authorization: `Bearer ${session}` },
cache: "no-store",
});
const pipeline = await res.json();
return <Pipeline data={pipeline} />;
}You can force the same result with export const dynamic = 'force-dynamic' at the segment level. We use that sparingly, mostly for routes where the dynamic trigger is buried in a helper and not obvious from the page file.
Search pages fall into the same category. Anything that reads searchParams depends on the query string and is rendered per request. If that becomes expensive, the fix is to cache the underlying data fetches, not the page.
Caches outside the server Even with the server side correct, users can still see old data because of the client-side router cache, which keeps recently visited route payloads in the browser during navigation. After a mutation in a server action, calling revalidatePath or revalidateTag also tells the client to refresh. For changes made elsewhere, router.refresh() from next/navigation re-fetches the current route.
If a CDN or NGINX sits in front of the app, check that it respects the Cache-Control headers Next.js sends and does not add its own caching on top. On self-hosted deployments with more than one instance, also remember that the default cache lives on each server's own memory and disk. Revalidating on one instance does not clear another unless you configure a shared cache handler.
A short checklist Set cache or revalidate options on every fetch rather than relying on defaults. Give each fetch a tag that matches how your content changes, such as one per record. Use on-demand revalidation for anything an editor expects to see immediately. Make per-user pages dynamic explicitly, not only by accident through a cookie read. Pre-render the paths that matter with generateStaticParams and let the rest render on demand. Check your CDN, proxy and multi-instance setup before blaming Next.js. Once every fetch states its intent, caching stops being something that happens to you. Most of the confusing behaviour we have seen came from one page silently inheriting a default that made sense for a different page.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789