Static vs Dynamic Rendering in Next.js: Caching Explained
Learn static vs dynamic rendering in Next.js: generateStaticParams, revalidate, fetch and route caching, Next 15 defaults, cache headers and real code.
By Matthews WongReviewed on
Key takeaway
A Next.js route is rendered either statically, as HTML made ahead of time at build or by revalidation and shared by every visitor, or dynamically, as HTML made for each request. generateStaticParams lists which dynamic paths to prerender. In Next.js 15, fetch calls and GET route handlers are no longer cached by default.
You will be able to
- Tell whether a route is static or dynamic, and what flips it.
- Prerender every page of a dynamic route and keep it fresh with revalidate.
- Apply the Next.js 15 caching defaults and set cache headers on purpose.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
From build to a fresh page
generateStaticParams feeds next build, which writes HTML for every path; visitors get that stored page, a revalidate window triggers a background rebuild, and a path outside the list returns 404 when dynamicParams is false.
Reviewed on 17 October 2026
Keys: ← → step · Home / End · K play or pause
A dynamic route such as a blog post has one template and many paths. Prerendering means making each path's HTML before anyone asks for it.
At build time, generateStaticParams runs first and returns an array of params. On this site it returns every post slug, and the locale layout returns both locales.
export function generateStaticParams()next build renders one page per path and stores the HTML. This is static rendering: the work happens once, ahead of time, and the result is shared across users and requests.
A visitor asks for a prerendered post. The server answers with the stored HTML instead of rendering again, so the cost is paid at build time, not per visit.
Predict the next step The revalidate window has passed and a visitor arrives. What do they get? (A wait while a fresh page renders · The stale page, now; a rebuild starts behind it · A 404)After the revalidate window, the next request still gets the stale page. A new version generates in the background, and the request after it receives the fresh page.
export const revalidate = 21_600;Predict the next step dynamicParams is false. What does a path missing from the list return? (It renders on demand · A 404 · The nearest stored page)With dynamicParams set to false, a path missing from generateStaticParams returns 404. The default, true, would render it on demand instead.
export const dynamicParams = false;
What is the difference between static and dynamic rendering?
With static rendering, the HTML is generated ahead of time, either at build time or through revalidation. The result is cached and shared across users and requests. With dynamic rendering, the HTML is generated at request time, which lets a page depend on who is asking. Static is the cheaper default for content that is the same for everyone, such as an article.
Read more (2)Show less
You rarely pick one by hand. Next.js decides from what the code touches. According to the docs, a component becomes dynamic if it uses cookies, headers, connection, draftMode, the searchParams prop, unstable_noStore, or fetch with cache set to no-store. Reading any of these says the output depends on the request, so the route cannot be prerendered.
Two practical notes. The next build output lists the rendering strategy of every route, so read it instead of guessing. And in development, pages are always rendered on demand and never cached, so next dev cannot show you how caching behaves. Run next build and next start to see the production behavior.
How does generateStaticParams prerender a dynamic route?
A dynamic segment such as [slug] is one template with many possible paths. generateStaticParams returns an array of objects, one per path, and Next.js statically generates those routes at build time instead of on demand. During next build it runs before the matching layouts and pages are generated, and it is not called again during revalidation.
Read more (3)Show less
This site uses it twice. The blog layout returns every slug in BLOG_META, and the locale layout returns the two locales. Because the locale segment sits above the blog segment, every post is prerendered once per locale. That is the reason a build cost grows with the number of posts, and the reason a visit costs almost nothing.
dynamicParams decides what happens to a path you did not list. The default, true, generates it on demand. Set to false, it returns 404. The locale layout here sets it to false, and its comment explains why: a request for a missing image file can match the blog route with locale equal to images, and rendering that combination ended in a 500. Now it is a clean 404.
You must always return an array, even an empty one. Returning an empty array renders every path the first time it is visited, while returning nothing makes the route dynamic.
How does revalidation keep a static page fresh?
Time-based revalidation is stale-while-revalidate. Export a revalidate number of seconds from the page, layout or route. After that time, the next request still returns the cached, now stale, page. The cache is invalidated and a new version generates in the background; once it succeeds, the following request gets the new page. If regeneration throws, the last good page keeps being served and Next.js retries on the next request.
Read more (2)Show less
The docs recommend a high value, such as an hour instead of a second. If you need precision, use on-demand revalidation: call revalidatePath or revalidateTag from a Server Action or Route Handler after the data changes. If you need real-time data, choose dynamic rendering. The open-source page here sets a six-hour window to match its data fetchers, so the page is not frozen at build time.
Two rules of the road. The lowest revalidate across the layouts, pages and fetch calls of a route sets the frequency for the whole route. And the value must be statically analyzable: a plain number works, but an expression such as 60 times 10 does not.
What changed about caching defaults in Next.js 15?
Next.js 15 flipped three defaults from cached to uncached. fetch requests are not cached by default; opt in per request with cache set to force-cache. GET Route Handlers are not cached by default; opt in with the dynamic route config set to force-static. And the client Router Cache now has a stale time of zero for page segments, so navigation reflects the latest page data.
Read more (2)Show less
Some behavior stays. Metadata routes such as sitemap.ts remain static unless they use dynamic functions. Shared layout data is not refetched on navigation, and back and forward navigation still restores from the cache. Also note that a route containing fetch calls can still be prerendered and its HTML cached; use the connection API if you must guarantee a route is dynamic.
Version note: these are the Next.js 15 docs, which match this site. Next.js 16 introduced Cache Components behind a flag, with a different caching model, so check which major version a tutorial describes before copying its config.
Where do Cache-Control headers fit in?
Rendering caches decide whether the server renders again. Cache-Control decides whether the browser or a CDN asks the server at all. They are separate layers. For ISR pages Next.js adds proper cache-control headers automatically, and since version 15 it no longer overrides a custom Cache-Control value with its own default, so a header you set in next.config is the one that goes out.
Read more (2)Show less
This site sets a one-year max-age with immutable on its image folder and its build chunks. MDN explains the pairing: max-age keeps a response fresh for that many seconds, and immutable says it will not change while fresh, which fits a URL that carries a hash or version. That is why the build chunks rule is production only: chunk names stay the same across edits in development, and a long cache there served stale code.
The rule of thumb: long and immutable for a URL whose content never changes, and short or revalidated for a URL whose content does. A file in the images folder that you overwrite under the same name breaks that promise.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
// Prerender every blog post at build time (x2 locales via the root layout's
// generateStaticParams). Without this, all 300+ posts were rendered on demand.
export function generateStaticParams() {
return Object.keys(BLOG_META).map((slug) => ({ slug }));
}One line turns the registry into a list of paths. The comment names the second half of the product: the locale layout above supplies the locales, so each slug is built once per language.
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
// Any `locale` outside generateStaticParams() 404s at the router, before a
// single segment renders. The middleware skips paths containing a dot so that
// public/ assets are served untouched, which means a request for a missing
// file like /images/blog/foo.jpg reaches the router and matches
// /[locale]/blog/[slug] with locale="images". Rendering that combination let
// next-intl read request headers during an on-demand static render, which
// Next.js rejects with "Page changed from static to dynamic at runtime" and
// serves as a 500. Rejecting unknown params up front makes it a clean 404.
export const dynamicParams = false;dynamicParams false is a safety rail, not only an optimization. The comment records a real failure: an unknown param reached an on-demand render that read request headers, and a static route is not allowed to do that.
// ISR: the fetchers set a 6-hour revalidate, but pin the route too so the page
// itself is regenerated on the same cadence rather than frozen at build time.
export const revalidate = 21_600;Data fetches carry their own revalidate, and the route carries one too. Pinning both keeps the page and its data on one cadence. The value is seconds, so this is six hours.
import { buildLlmsTxt } from "@/lib/llms"
export const dynamic = "force-static"A GET Route Handler is not cached by default in Next.js 15. This handler has no per-request input, so it opts back in with force-static and is built once like a page.
{
source: "/images/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
}, // Cache images for 1 year
],This header is about the browser and CDN, not about rendering. It is safe only because image files are treated as never edited in place: a changed picture gets a new file name.
Mistakes that cost real time
A page you expected static is dynamic
One cookies, headers or searchParams read makes the route render on every request. Read next build output, find which API the route touches, and move it into a small component, or keep it out of the route.
fetch results are never cached after upgrading
Next.js 15 does not cache fetch by default. If a page got slower after an upgrade from 14, opt in on the call with cache force-cache, or set a revalidate time, instead of assuming the old default.
You test revalidate in next dev and nothing happens
In development, pages are always rendered on demand and never cached. Build and start the production server to test ISR, and expect the first request after expiry to return the stale page.
An unknown path returns a 500, not a 404
With dynamicParams left at true, an unexpected param can reach an on-demand render that needs request data. This site hit it with a missing image URL. Set dynamicParams to false where the list is complete.
Immutable on a URL that changes
A one-year immutable header means visitors keep the old bytes. Use it only for hashed or never-edited files, and give a replaced file a new name.
| Mode | HTML is made | Good for | Opt in with |
|---|---|---|---|
| Static | At build time | Content the same for everyone | generateStaticParams |
| Revalidated static | At build, then in the background | Content that changes now and then | revalidate, revalidatePath, revalidateTag |
| Dynamic | On every request | Content that depends on the request | cookies, headers, searchParams, no-store |
Do it on your own machine
Check these on your own Next.js 15 project, with the production server rather than next dev.
Build and read the route list. Find one dynamic route and note how its paths are reported.
npm run buildAdd generateStaticParams to a dynamic route with two ids, rebuild, and confirm both paths appear in the output.
Set dynamicParams to false, start the server, and request an id you did not list. Expect a 404.
npm startRead the Cache-Control header of a page and of a file under a folder you configured, and compare them.
curl -sI http://localhost:3000/ | grep -i cache-control
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.Why does this site prerender each blog post twice?Show the answer
Answer:The blog layout's generateStaticParams returns every slug and the locale layout above it returns both locales, so each slug is built once per locale.
2.What does dynamicParams equal false change?Show the answer
Answer:A path that generateStaticParams did not list returns 404 instead of being rendered on demand.
3.A route handler for a file that never changes needs which setting to be built once?Show the answer
Answer:export const dynamic = force-static, because GET handlers are not cached by default in Next.js 15.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.
- Next.js 15 docs: Caching and Revalidating
- Next.js 15 docs: generateStaticParams
- Next.js 15 docs: Route Segment Config
- Next.js 15 docs: Incremental Static Regeneration
- Next.js 15 docs: static and dynamic rendering
- Next.js docs: caching without Cache Components
- Next.js blog: Next.js 15, caching semantics
- MDN: Cache-Control