SSR, SSG or ISR: Decide by the Cost of Being Stale
The question is not whether a page is dynamic, it is what it costs you for that page to be thirty seconds stale, and that answer picks the rendering mode.
- Next.js
- App Router
- SSR
- ISR
- Rendering
Ask what staleness costs, not whether the page is dynamic
Most teams pick a rendering mode from the framework's vocabulary, server rendering, static generation, incremental static regeneration, and then argue about which one the project should standardise on. That argument is usually unwinnable, because it is being had at the wrong altitude. The useful question is a business one, and it has to be asked per page: what does it cost you if this page is thirty seconds out of date? A price that is thirty seconds stale is a wrong price. A location page that is thirty seconds stale is still, for every practical purpose, a location page. Same framework, opposite answers, and the cost of being wrong is what separates them.
In the App Router these three modes map onto three concrete mechanisms, and it is worth being precise about what each one does before deciding which page gets which. Dynamic rendering runs the page on the server for every request, so whatever changed a second ago is already in the response the browser receives. Static generation runs once, at build time, commonly driven by generateStaticParams for a route that has many possible paths, and the result is plain HTML served from that deploy until the next one replaces it. Incremental static regeneration sits between the two: the page is built once, and a revalidate window tells the framework how long that build is allowed to stand before it is rebuilt in the background, without anyone triggering a new deploy.
I don't start by asking whether a page is dynamic. I ask what it costs if this page is thirty seconds stale, and that answer decides which of the three mechanisms above the page gets. The framework's vocabulary comes second, not first.
- Dynamic: rendered on request, correct to the moment, paid for on every hit.
- Static: built once, fast and crawlable, wrong until the next deploy.
- ISR: built once, then allowed to go stale for a window you choose, refreshed without a deploy.
This is a decision made per page, not once for the whole project. A single route file can hold more than one answer at the same time: a shell that is static while a fragment inside it stays dynamic, which is the case worked through later in this piece. Treating rendering mode as a project-wide setting, chosen once and left alone, is the assumption that makes this topic feel harder than it actually is.
Dynamic: when stale means wrong
Everything in the booking engine on the platform I worked on, the listing, the detail page, checkout and the booking step itself, is server rendered on request. Nothing in that path is served from a build. Price resolves from the day the booking is made, the day the car is collected, the duration, and business rules layered on top of that, plus whatever is sitting in the customer's cart at that moment. None of those inputs are known at build time, and most of them can change from one request to the next for the same visitor.
A stale price is a wrong price. There is no staleness window on that statement that is acceptable, not thirty seconds, not three. So there is no version of caching, revalidation or static generation that belongs anywhere near that page. It gets rendered on request, every time, and the cost of doing that on every hit is simply accepted, because the alternative cost, showing a customer a number that is not the number they will actually pay, is worse.
- Anything priced per user, per session or per inventory count. If the number can move between two requests from the same visitor, it has to be resolved on the request that shows it.
- Anything that reflects a live count you are about to promise against: stock, availability, a seat, a slot. Showing one that is no longer true creates an obligation you then have to honour or withdraw.
- Anything gated by who is asking. A logged in state, a permission, a discount tied to an account, cannot be baked into a build that is identical for every visitor.
The pattern behind all three is the same: the page is not describing the world, it is making a claim the business then has to stand behind. A static page that is wrong is an editorial slip, fixed on the next deploy. A dynamic page that is wrong because it was allowed to be stale is a claim the business did not mean to make.
Static: the SEO surface
Vehicle pages, location pages and direct response landing pages, on the platform I worked on, are statically generated at build: generateStaticParams enumerates the paths, and the build renders each one, fetching its content from the API as it goes. Nobody is waiting on a server to compute anything when a visitor or a crawler requests one of these; the HTML already exists, sitting behind a CDN, before the request arrives.
858 pages are live this way across the two platforms: 532 on Thrifty, 326 on Cariva. That is not a small content operation, and it is not accidental that all of it sits on static generation rather than anywhere else. These are the pages whose entire job is to be found, read quickly and indexed correctly, and the fastest, most crawlable page is one that was already HTML before anyone asked for it.
What static generation buys you is speed and crawlability that do not depend on the health of a server at the moment someone asks. What it costs you is a deploy every time the content changes. That is a real cost, and it is worth being honest about who pays it: usually not the developer, who can run a build in minutes, but the person in marketing who wants a headline changed on a landing page this afternoon and is now waiting on a release.
Static generation is the right fit exactly when the rate of change on a page is slower than the rate at which you are willing to deploy. A vehicle page changes when the fleet changes. A location page changes when a branch opens, closes or moves. Both of those are editorial events, not per visitor events, and an editorial cadence is precisely what a build cycle is built to serve.
ISR: when the content expires but the deploy cannot wait
Offers sit in the third mode. An offer has a validity window; once it closes, no customer can apply it and nobody should be able to submit a lead against it. That page has to refresh itself without anyone running a deploy, but it does not need to be correct to the second, which is a very different requirement from the booking engine's.
Incremental static regeneration gives you two shapes for that requirement. The simpler one is a time based revalidate window on the route: the page is served from the last build until the window elapses, at which point the next request triggers a rebuild in the background and everyone after that gets the fresh version. The other shape is on demand revalidation, triggered by an event, an editor publishing a change, rather than by a clock. That trades a small amount of infrastructure, something has to call the revalidation, for pages that update the moment the underlying data actually changes, instead of waiting out a fixed window regardless of whether anything changed at all.
// Illustrative shape only, not source from any specific platform.
export const revalidate = 300; // five minutes: shorter than any offer's real lifespan
export default async function OfferPage({params}: {params: Promise<{slug: string}>}) {
const {slug} = await params;
const offer = await getOffer(slug); // fetched fresh at most every five minutes
if (!offer || offer.expiresAt < new Date()) return notFound();
return <OfferDetail offer={offer} />;
}Choosing the window is a judgement call the framework will not make for you, and the framework's minimum is not the right anchor for it. The practical rule is to set the window to the shortest interval at which someone would actually notice the page is wrong, not the shortest interval the framework allows. An offer that runs for two weeks does not need a revalidate window measured in seconds; a window measured in minutes is already faster than any customer's patience, and it is a great deal cheaper to run.
The page that is both
The location page is static, but its availability strip is not. A location page's copy, address, hours, imagery, directions, is exactly the kind of editorial content that belongs on a build cycle. But the same page usually needs to show whether there is a car available at that branch right now, and availability is closer in spirit to the booking engine than to the copy sitting next to it.
The tempting fix is to downgrade the whole page to dynamic so the one live component has somewhere to live. That throws away everything static generation was buying you, speed and crawlability for content that never needed to be live, to solve a problem that only affects one component. The better fix is to keep the shell static and stream the dynamic fragment inside it using Suspense, so the page that reaches the browser, and the page a crawler sees, is the fast static shell, with the availability strip arriving a moment later as its own small render.
// Illustrative shape only, not source from any specific platform.
import {Suspense} from 'react';
export default async function LocationPage({params}: {params: Promise<{slug: string}>}) {
const {slug} = await params;
const location = await getLocationContent(slug); // static: fetched at build time
return (
<article>
<LocationHero location={location} />
<LocationCopy location={location} />
<Suspense fallback={<AvailabilitySkeleton />}>
<AvailabilityStrip branchCode={location.branchCode} />
</Suspense>
</article>
);
}
async function AvailabilityStrip({branchCode}: {branchCode: string}) {
const availability = await getAvailability(branchCode); // resolved on request
return <AvailabilityList availability={availability} />;
}What this costs is worth stating plainly, because it is easy to assume streaming is a free upgrade. The shell is cacheable and indexable; the fragment inside the Suspense boundary is neither, it is computed fresh on every request, the same as any other dynamic content. Anything a crawler needs to see, and anything you want indexed, has to live in the shell. If the only place a fact appears is inside the streamed fragment, a visitor sees it and a search engine may not, because the fragment is not part of the HTML that was already sitting there waiting.
The two ways teams get this wrong
There are two failure modes here, and they sit on opposite ends of the same mistake: choosing a mode for the whole project instead of asking the staleness question per page.
The first is making everything dynamic, usually out of caution, because a dynamic page is never wrong. It is also never cheap. You pay to render pages nobody personalised, on every single hit, the SEO surface gets slower because every crawl request now waits on a server instead of reading a file that was already there, and your origin becomes the thing that falls over the moment a campaign lands and traffic spikes on pages that never needed to be recomputed in the first place.
The second is making everything static, usually out of a desire for speed, and it fails in the other direction. A wrong price shown because a pricing page was cached is not a performance bug, it is a commercial one. An expired offer that a static page still shows as live generates a lead nobody can honour, which is a commercial problem before it is a technical one.
| Page type | What staleness costs | Rendering mode |
|---|---|---|
| Booking listing, detail, checkout | A wrong price, a wrong availability count, a booking on the wrong terms | Dynamic, on request |
| Vehicle and location pages | A deploy delay before an editorial change goes live; nothing shown is ever actually wrong | Static, at build |
| Offer pages | A lead or a redemption against terms that no longer apply | ISR, revalidated on a window |
| Location page availability strip | The same as booking, inside a page that is otherwise static | Dynamic fragment streamed in a static shell |
How to check what you actually shipped
Deciding the rendering mode in code is only half the job; confirming it actually shipped that way is the other half, and it is worth doing as a habit rather than trusting the file you wrote.
- Read the build output. A framework build lists every route with a symbol indicating how it was rendered, static, dynamic or revalidated on an interval. Confirm each route's symbol is the one you intended, not the one the framework defaulted to because a data call inside it forced a different outcome.
- Check the response headers on the deployed page. A cached, statically served response and a freshly rendered one carry different caching signals from whatever is sitting in front of the framework; look at what is actually being returned rather than assuming it from the source code.
- View source, not the rendered page in developer tools. Confirm the text a crawler needs to see is present in the HTML the server sent, rather than arriving afterwards as the page hydrates. If a fact only appears once you watch the page settle in a browser, a crawler that does not run your JavaScript will not see it either.
- Confirm a revalidated page actually changes without a deploy. Change the underlying data, wait out the revalidate window, and reload. If the page is still showing the old value after the window has passed, the revalidation is not wired to the data you think it is.
None of this needs guessing. What it needs is checking the actual output rather than trusting that the code you wrote produced the render you intended, because a single data call with the wrong caching behaviour, or a component that quietly opts a whole route out of static rendering, is enough to move a page into a different mode from the one on the file.
The short version
Turn the thesis into something you can run over your own routes.
- 1
List every route
Write down every page type in the project, not every URL, every distinct kind of page.
- 2
Ask the staleness question for each
For each page type, write one sentence: what happens if this page is thirty seconds out of date. That sentence is the entire brief.
- 3
Assign the mode the answer implies
Nothing happens: static. Something expires or needs to disappear on a schedule: ISR, with a window set to when someone would notice. Something is wrong the moment it is stale: dynamic.
- 4
Look for pages that are actually two pages
If one part of a page answers the staleness question differently from the rest, it is not one page, it is a static shell and a dynamic fragment. Treat them separately.
- 5
Verify what shipped, not what you wrote
Read the build output, check the response headers, view source, and confirm a revalidated page changes without a deploy before you consider the decision closed.
None of this is a framework problem to solve once and forget. It is a question to ask again every time a new page type appears, and the answer is allowed to be different for the tenth page than it was for the first. If you want a second opinion on how your own routes are actually rendering, Next.js development is where that work lives, and the platform work shows the scale this approach was built to hold.
What should you know about how I work on this?
Not inherently. Googlebot renders JavaScript and can index a dynamically rendered page perfectly well. The risk is speed, not visibility: rendering on every request means every crawl request also waits on a server, and a slow response can affect how often a crawler comes back, not whether it can read the page. Static generation removes that variable entirely for content that never needed to be live.
Set it to the shortest interval at which someone would actually notice the page is wrong, not the shortest interval the framework allows. An offer running for two weeks does not need a window measured in seconds; a window measured in minutes is already faster than anyone will notice, and it costs far less to run than rebuilding on every request.
Yes. A static shell can stream a dynamic fragment inside it using Suspense, so the page a crawler sees, and the first thing a visitor's browser paints, is the fast static version, while the fragment that genuinely needs to be live resolves separately. The cost is that anything inside that fragment gives up the shell's caching and crawlability, so the boundary should sit around the smallest thing that actually needs it.
Yes, that is the normal case rather than the exception. generateStaticParams tells the build which paths exist, the build fetches each one's content from the API, and the result is stored as plain HTML. The API only has to be available at build time, not at request time, which is part of what makes static pages fast and resilient.
Nothing, until either a new deploy runs or the page is put on a revalidate window. A purely static page shows exactly what existed at the last build, indefinitely. That is the right behaviour for content on an editorial cadence and the wrong behaviour for anything with a validity window, which is the distinction that separates static generation from ISR.