Key Takeaway
How Next.js caching works: a route renders statically, meaning its HTML is made at build time and shared by every visitor, unless its code reads cookies, headers, searchParams or other per-request data. Read the circle, dot and f symbols in the next build output, list paths with generateStaticParams, refresh with revalidate, and prove it with curl on production mode.
You change what a page shows, deploy, and the page still shows the old data. Another day it is the opposite: a page you thought was static is rendered again on every visit, or the time printed on the page never changes. It all comes down to one question: is this route rendered at build time, or when a request arrives?
This guide builds four small routes in Next.js 15 (App Router) and checks each one with two tools you always have: the next build output and the response headers from curl. Every option is matched against the Next.js documentation listed in the sources. I read the header function and the build output symbols in the Next.js 15.5.7 code installed in this site's project, and on this site's blog post pages I did see the headers x-nextjs-cache: HIT and cache-control: s-maxage=31536000.
Want the concept as a simulation?
This guide focuses on steps and errors. Our free lesson explains the flow from build to a fresh page with an animation, plus a quiz on what makes a route dynamic and what the fetch default is in Next.js 15: Static vs Dynamic Rendering in Next.js: Caching Explained. Work through this guide first so you have four routes to inspect, then open the lesson to see how this site uses them.
What you need
Everything runs on your laptop, with no server and no extra service.
- A Next.js 15 project with the App Router and TypeScript, for example one made by create-next-app.
- Two terminals. One runs npm run build and then npm start, the other runs curl. Every caching test here uses the production server, not npm run dev.
- curl in the terminal, because we inspect the HTML and the response headers, not what a browser shows.
- No database or external API is needed. Every example uses its own time and numbers so the result is predictable.
Step 1: Create a static page and read the symbols in the build output
Start with a page that is clearly static. It prints the time at which it was rendered. If it really is rendered once at build time, that time will be frozen. Test it in production mode, because according to the docs, in development pages are always rendered on demand and never cached.
// app/about/page.tsx
// Reads no cookies, headers or searchParams, so Next.js renders it at build time.
export default function AboutPage() {
// Computed ONCE, during next build. Every visitor gets the same HTML.
const builtAt = new Date().toISOString();
return (
<main>
<h1>About</h1>
<p>
Rendered at: <time>{builtAt}</time>
</p>
</main>
);
}
// Terminal 1 (production mode, NOT npm run dev):
// npm run build
// npm start
//
// Terminal 2: run it twice and compare.
// curl -s http://localhost:3000/about | grep -o "<time>[^<]*</time>"
// curl -s http://localhost:3000/about | grep -o "<time>[^<]*</time>"Expected output: next build prints the route list with one symbol before each line, and /about is marked with the circle symbol (Static), meaning prerendered as static content. Two curl calls in a row return exactly the same time value: the build time, not the request time.
Only three symbols matter. The circle (Static): HTML made at build time. The filled dot (SSG): static HTML made from generateStaticParams. The f symbol (Dynamic): rendered on the server for every request. The legend is printed under the route list, so you never have to guess. A fourth symbol, the half circle (Partial Prerender), only appears when Partial Prerendering is on, which is outside this guide.
Step 2: Create a dynamic route and see what flips it
Now a route that really must render per request. The most honest way is await connection() from next/server, which tells Next.js that rendering must wait for an incoming request. The docs use it for values such as new Date() or Math.random(). These have the same effect: cookies, headers, draftMode, the searchParams prop, unstable_noStore, and fetch with cache set to no-store. Read just one and the route is dynamic.
// app/now/page.tsx
import { connection } from "next/server";
export default async function NowPage() {
// Wait for a real request before rendering anything below this line.
await connection();
const now = new Date().toISOString(); // computed per request
return (
<main>
<h1>Now</h1>
<p>
Rendered at: <time>{now}</time>
</p>
</main>
);
}
// Wrong: adding this to app/about/page.tsx "just to read a cookie".
// import { cookies } from "next/headers";
// const session = (await cookies()).get("session"); // Next.js 15: cookies() is async
// The whole /about route silently becomes dynamic.
// Right: keep /about free of request data, and read the cookie only in the
// route that really needs it.
// npm run build -> /now is marked as dynamic, /about stays static
// npm start
// curl -s http://localhost:3000/now | grep -o "<time>[^<]*</time>"
// curl -s http://localhost:3000/now | grep -o "<time>[^<]*</time>"Expected output: The build marks /now with the f symbol (Dynamic) while /about stays a circle. Every curl to /now returns a different time, because the HTML is made again on every request.
The biggest beginner trap: one line of cookies() or one searchParams prop in a static page turns the whole route from a circle into the f symbol, with no error message at all. Every time you add code that reads the request, run next build again and look at the symbol.
Step 3: Prerender a parameterised route with generateStaticParams
A route such as /produk/[id] is one template with many paths. generateStaticParams returns an array of objects, one per path, and Next.js builds those pages at build time instead of on demand. In Next.js 15, params is a Promise, so you write await params. Then dynamicParams set to false makes a path that is not listed return 404. The default, true, renders that path the first time it is requested.
// app/produk/[id]/page.tsx
// Any id not returned below answers 404 instead of rendering on demand.
export const dynamicParams = false;
// Runs once, at build time, before the page is generated.
export function generateStaticParams() {
return [{ id: "1" }, { id: "2" }, { id: "3" }];
}
export default async function ProdukPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Next.js 15: params is a Promise.
const { id } = await params;
const builtAt = new Date().toISOString();
return (
<main>
<h1>Produk {id}</h1>
<p>
Rendered at: <time>{builtAt}</time>
</p>
</main>
);
}
// npm run build -> /produk/[id] is marked as SSG, with paths listed under it
// npm start
// curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/produk/1
// curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/produk/99Expected output: The build marks /produk/[id] with the filled dot (SSG), with some paths listed under it. After npm start, /produk/1 answers 200 and /produk/99 answers 404.
One more rule: generateStaticParams must always return an array, even an empty one. An empty array means no path is built at build time, but every path is rendered the first time it is visited and then stored. Returning nothing makes the route dynamic.
Step 4: Add revalidate so a static page does not freeze
A static page can go stale. export const revalidate = 60 means: after 60 seconds the next request still gets the old page while a new version is built in the background, and the request after that gets the new one. The pattern is called stale-while-revalidate. The docs recommend a high value, such as an hour, and revalidatePath or revalidateTag when you need precision.
// app/produk/[id]/page.tsx (add one line near the top)
// Wrong: export const revalidate = 60 * 10; (not statically analysable)
// Right: a plain number, in seconds.
export const revalidate = 60;
// Rebuild, restart, then run this in terminal 2:
// for i in 1 2; do
// curl -s http://localhost:3000/produk/1 | grep -o "<time>[^<]*</time>"
// done
// sleep 65
// curl -s http://localhost:3000/produk/1 | grep -o "<time>[^<]*</time>" # still the OLD time; regeneration starts
// sleep 3
// curl -s http://localhost:3000/produk/1 | grep -o "<time>[^<]*</time>" # the NEW timeExpected output: The first two curl calls show the same time. The first request after the 60 seconds still shows the old time, and the request after that shows a new one. If it still shows the old time, wait a moment and repeat: the new version is generated in the background.
The revalidate value must be statically analysable: 600 is valid, 60 * 10 is not. And the lowest revalidate across the layouts, pages and fetch calls of a route sets the frequency for the whole route.
Step 5: Set fetch and route handlers on purpose
Two defaults changed in Next.js 15 and make many older tutorials misleading. fetch is not cached by default, and neither is a GET Route Handler. Opt in as needed: cache force-cache or next.revalidate for fetch, and dynamic force-static for the handler. fetch with no-store, or revalidate 0, makes the route dynamic.
// Inside any async Server Component. The URL is a placeholder.
// Wrong (a Next.js 14 habit): expecting this to be cached.
const a = await fetch("https://api.example.com/produk");
// Right: opt in on the call.
const b = await fetch("https://api.example.com/produk", { cache: "force-cache" });
const c = await fetch("https://api.example.com/produk", { next: { revalidate: 3600 } });
// This one makes the route dynamic.
const d = await fetch("https://api.example.com/produk", { cache: "no-store" });
// app/api/waktu/route.ts
// A GET handler is NOT cached by default in Next.js 15. This one has no
// per-request input, so opt back in. Delete the line to see it become dynamic.
export const dynamic = "force-static";
export function GET() {
return Response.json({ builtAt: new Date().toISOString() });
}
// curl -s http://localhost:3000/api/waktu
// curl -s http://localhost:3000/api/waktuExpected output: With force-static added, /api/waktu shows as a circle in the build output and two curl calls return the same builtAt. Remove the line and the route becomes the f symbol.
Two notes. First, a route that contains fetch calls without options can still be prerendered and its HTML cached; if you must guarantee a dynamic route, use connection(). Second, all of this describes Next.js 15. Next.js 16 introduces the Cache Components model with use cache, so check which version a tutorial describes before copying its config. The comparison is in the Cache Components and use cache article.
Step 6: Prove it with the response headers
The build symbols tell you what Next.js planned. The headers tell you what actually went out. In Next.js 15.5.7, the getCacheControlHeader function produces an s-maxage equal to the revalidate value, a one-year s-maxage (31536000 seconds) when revalidate is not set, and private, no-cache, no-store, max-age=0, must-revalidate when revalidate is 0.
# -I asks for the headers only. grep keeps the lines that matter.
curl -sI http://localhost:3000/about | grep -iE "^(HTTP|cache-control|x-nextjs)"
curl -sI http://localhost:3000/produk/1 | grep -iE "^(HTTP|cache-control|x-nextjs)"
# The rule behind the numbers, from getCacheControlHeader in Next.js 15.5.7:
# revalidate = 60 -> cache-control starts with s-maxage=60
# revalidate not set -> s-maxage=31536000 (one year)
# revalidate = 0 -> private, no-cache, no-store, max-age=0, must-revalidateExpected output: /about carries s-maxage=31536000, and /produk/1 carries s-maxage=60 at the start of its value. The x-nextjs-cache and x-nextjs-prerender headers are not in the official docs, but they appear on prerendered pages in Next.js 15.5.7 (on this site's blog post pages I saw HIT and 1). Treat both as debugging hints, not a contract.
Cache-Control decides whether a browser or a CDN asks the server at all, while the render cache decides whether the server renders again. They are separate layers. If you set Cache-Control yourself in next.config, Next.js 15 no longer overrides it with its own default, so the header you wrote is the one that goes out.
Add NEXT_PRIVATE_DEBUG_CACHE=1 to your .env file before npm start. According to the ISR docs, the server console then logs ISR cache hits and misses, so you can see which pages are generated during the build and which are updated as paths are accessed.
Common errors and how to fix them
Almost every caching problem in Next.js falls into these eight symptoms. Match yours against the first column.
| Symptom | Cause | Fix |
|---|---|---|
| A page you expected to be static is the f symbol in the build output | The route reads cookies, headers, searchParams, connection, or fetch with no-store. | Find the API it touches, move it to another route or keep it out of that route, then rebuild and check the symbol. |
| The data changed but the page still shows the old version | The page is prerendered with no revalidate, or the window has not passed. The first request after expiry still gets the old version by design. | Set revalidate, or call revalidatePath or revalidateTag after the data changes. Refresh twice before concluding anything. |
| You test revalidate in npm run dev and nothing happens | In development, pages are always rendered on demand and never cached. | Run npm run build and then npm start, and test there. |
| A time or random number on the page never changes | The value was computed at build time in a static route, and the HTML is reused. | Add await connection() to render per request, or use revalidate if a periodic refresh is enough. |
| A valid-looking path returns 404 on the production server | dynamicParams is false and the path is not in generateStaticParams. | Add the path to the list, or remove dynamicParams so a new path renders on its first request. |
| A page got slower after upgrading to Next.js 15 | fetch is no longer cached by default, so every render calls the data source again. | Add cache force-cache or next revalidate to fetch calls whose data may be reused. |
| A GET route handler shows as the f symbol though its content never changes | In Next.js 15, GET handlers are not cached by default. | Add export const dynamic = force-static to the route.ts file. |
| revalidate = 60 * 10 does not work the way you meant | The revalidate value must be statically analysable, and the docs call an expression like that invalid. | Write a literal number: revalidate = 600. |
Checklist before you call caching done
- You tested with npm run build and npm start, not npm run dev.
- Every route in the build output has the symbol you expected: circle, dot or f.
- Parameterised routes have generateStaticParams, and you chose dynamicParams on purpose.
- Pages whose data changes have a literal revalidate number, or are triggered by revalidatePath and revalidateTag.
- The fetch calls and GET Route Handlers that may be cached are opted in explicitly.
- You read the Cache-Control header with curl -I instead of guessing.
Go deeper
This guide teaches how to check. The free lesson explains why: a simulation from generateStaticParams to background regeneration, real code from this site (the blog layout, the locale layout with dynamicParams false, the open-source page with revalidate), and a three-question quiz.
Open the lesson: Static vs Dynamic Rendering in Next.js
Once your routes are static on purpose, continue with the other optimisations in the Next.js performance optimisation guide.

