Skip to content

    Frontend System Design

    Choosing a Rendering Strategy Per Page

    A decision framework for picking CSR, SSR, SSG, ISR or streaming for each page: five questions, a freshness budget, worked examples and failure modes.

    By Published 11 min read

    • frontend-system-design
    • rendering
    • ssr
    • isr
    • decision-framework
    Share

    Standard lessonintermediateInteractive simulatorFrontend System Design

    Before this: Rendering Strategies: CSR, SSR, SSG, ISR and Streaming

    What you'll learn: By the end of this lesson you will be able to choose a rendering strategy for each page of a product with five questions instead of a favourite, express "how fresh must it be" as a freshness budget and map it to a mechanism, split one page into a shared shell plus personal and live parts, defend the choice for seven common page types, and recognise the early signs that a page is on the wrong strategy.

    An airport has three kinds of information. The printed brochure rarely changes. The departures board changes every few minutes and everyone reads the same one. Your boarding pass is yours alone. Nobody argues about which medium the whole airport should use: each kind of information gets the medium that fits how it changes and who reads it.

    Web pages are the same. Rendering Strategies: CSR, SSR, SSG, ISR and Streaming defines each strategy and gives a first flowchart for a store. This lesson goes one step further: a repeatable way to decide, page by page and part by part, and to notice when you decided wrongly. Its running example is Gigline, a fictional event-ticketing site (an invented product, so all numbers are illustrative) with a landing page, a help centre, event pages, search results, "my tickets", an organiser dashboard and a checkout.

    Framework names move. The framework-specific behaviour below comes from the Next.js documentation for the 16.x line as read on 2026-11-08 (16.4 was the latest release in VERSIONS.md); other frameworks use other names for the same ideas.

    The core idea

    Choose a strategy for the shell of each page and for each hole in it, not for the site. One guideline does most of the work: pick the most static option that still meets the page's freshness budget and its personalisation needs. Static output is typically the fastest and most resilient to serve, so move away from it only when a constraint forces you to.

    Figure 1. One page, four lifetimes

    An event page: each part gets the lifetime that fits itShell to Browser (built ahead); Shared fragment to Browser (cached, in the HTML); Personal hole to Browser (streamed in); Live value to Browser (fetched after load)ONE PAGE, FOUR LIFETIMESShell: title, layoutShelltitle, layoutShared fragment: cached, refreshedSharedfragmentcached, refreshedPersonal hole: per requestPersonal holeper requestLive value: seconds oldLive valueseconds oldBrowserBrowserbuilt aheadcached, in the HTMLstreamed infetched after load

    How to read this: the top two parts can be produced ahead of time and served as files, the bottom two depend on the visitor or the moment. Cutting a page along these lines is what lets most of it stay static.

    The five questions

    Ask them in this order, because the early ones are hard constraints and the later ones are tuning.

    1. Must the first HTML contain the content? Google says it queues pages for rendering with a headless Chromium and that server-side or pre-rendering "is still a great idea", partly because not every bot runs JavaScript. Next.js detects HTML-limited bots and, with Cache Components, renders the page dynamically for them so the metadata is in the document head. If the page is public and should be found or previewed, yes. If it sits behind a login, no.
    2. Is it the same for every visitor? web.dev names personalisation as the type of request that does not work well with static rendering. A shared cache can only hold what everyone may see.
    3. What is the freshness budget? How old may the content be before someone is harmed, expressed as a number the business agrees to.
    4. How many URLs, and can you list them? web.dev notes that static rendering needs an HTML file per URL, which can be challenging or even infeasible when you cannot predict the URLs.
    5. What should happen when the data source is down? A pre-rendered page keeps working. A request-time render can fail along with its source unless you handle the error.

    Figure 2. The decision, in constraint order

    flowchart TD accTitle: Choosing a strategy from constraints accDescr: First, a page behind a login that is not indexed can be client rendered. Otherwise, if content varies per visitor, split the personal part into a hole and decide the rest. For the shared part, a freshness budget of at least a deploy means static generation, minutes to hours means incremental regeneration or a cached fragment, and seconds means a request-time render or a small live fetch. S(["A page or a part of one"]) --> L{"Behind login and
    not indexed?"} L -->|"yes"| C["CSR, or SSR if first paint matters"] L -->|"no"| V{"Same for
    every visitor?"} V -->|"no"| H["Split: shared shell +
    personal hole (stream or fetch)"] V -->|"yes"| B{"Freshness
    budget?"} H --> B B -->|"a deploy or longer"| G["SSG"] B -->|"minutes to hours"| U{"URLs listable?"} U -->|"yes"| I["ISR or cached fragment,
    on-demand invalidation"] U -->|"no"| D["Generate on first request,
    then cache (ISR on demand)"] B -->|"seconds"| R["Request-time render, or a
    small live fetch into a static page"]

    How to read this: login and personalisation come first because they rule options out entirely. The freshness budget comes next and chooses among what remains.

    The freshness budget

    Write the budget as a sentence with a number: "an event's description may be up to 10 minutes old; seat availability up to 5 seconds." Then map it (illustrative values):

    Budget Typical mechanism Who pays
    Until the next deploy SSG: files built once Build time grows with page count
    Minutes to hours, known change events ISR with time-based plus on-demand revalidation A regeneration per page per window, and a way to call invalidation
    Seconds Request-time render, or a tiny uncached fetch Origin load per visit
    Per user Never in a shared cache Origin or client work per visitor

    The Next.js ISR guide documents the mechanism: after the revalidate window the next request still receives the stale page, regeneration starts in the background, and the request after it gets the new page. It recommends a high revalidate time (one hour rather than one second) and on-demand revalidation (revalidatePath, revalidateTag) when you need precision, and real-time data should use dynamic rendering. So a page can be older than its revalidate value, which the budget must allow for.

    Figure 3. Stale-while-revalidate for one page

    sequenceDiagram accTitle: Incremental static regeneration timeline accDescr: With a revalidate window of 60 seconds, a request after the window still receives the stale page while regeneration runs in the background. The next request receives the new page. participant A as Visitor A (t=61 s) participant S as Server and cache participant B as Visitor B (t=65 s) A->>S: GET /events/42 S-->>A: cached page (now stale) Note over S: regenerate in the background S->>S: new page stored B->>S: GET /events/42 S-->>B: new page

    How to read this: 60 seconds is illustrative. The visitor who triggers regeneration is not the one who sees the result, so budget for one extra stale response.

    Splitting a page into a shell and holes

    Most pages fail a single-strategy test because one part is personal or live. Cut the page instead. In the Next.js 16.x docs, Cache Components does this: static content and results cached with use cache become part of a prerendered static shell, while reads of cookies(), headers(), searchParams or uncached data go behind <Suspense> and stream at request time. The docs call this approach Partial Prerendering and note that reading cookies() no longer opts the whole route into dynamic rendering, "the way the previous rendering model did". Check which model your app uses.

    Three rules follow, each from the docs:

    • Push dynamic access down. If a layout awaits cookies() or params at the top, everything under it becomes request-time. Resolve them in the small component that needs them.
    • Keep the LCP element out of boundaries. An element inside a Suspense boundary cannot paint until its content is swapped in.
    • Decide status codes before streaming. The 200 is committed with the first chunk, so a late notFound() cannot become a 404 (Next.js adds noindex instead). Check existence before the boundary.

    Worked examples: Gigline

    Illustration 1, the minimal example: two pages, five questions

    A refund-policy article: public and indexed, identical for everyone, a budget of "a day", 2,000 articles that you can list, and source data that is a file. Answers: SSG. The "my tickets" page: behind login, per user, budget of seconds, not indexed. Answer: CSR, or an SSR shell if first paint matters. Same questions, opposite results.

    The seven pages

    Page Shared? Budget URLs Choice and reason
    Landing page yes a deploy 1 SSG. A greeting is a streamed or client hole.
    Help article yes a day 2,000, listable SSG, rebuilt on content changes.
    Event detail shell yes description minutes, seats seconds 60,000, changing ISR for the shell, seats as a live hole.
    Search results yes per query minutes unbounded Request-time, cached per query, not pre-rendered.
    My tickets no seconds one route CSR behind login, or SSR with no shared cache.
    Organiser dashboard no seconds one route Client or streaming SSR: no search benefit.
    Checkout no exact one route Request-time and uncached; correctness first.

    Illustration 2, the realistic example: the event page

    Details (title, venue, description) change when an organiser edits them, which Gigline knows about. That makes them an ISR shell with on-demand revalidation. The price and seats-left change in seconds, so they are a small uncached fetch or a streamed hole. The "saved" heart depends on the visitor and is a client or streamed hole. The shell is a file at the CDN, which is why a traffic spike does not reach the origin.

    Illustration 3, the edge case: search results

    Search results are the pages you cannot pre-render, because the set of queries is open-ended (question 4). Generating a file per query is infeasible, and a time-based page cache is the wrong tool when nearly every query is unique. Choose request-time rendering with a short shared cache keyed by the query, or a static shell with client-side results. Decide separately whether such pages should be indexed.

    Figure 4. Gigline: where each route is produced

    Gigline routes by where and when their HTML is producedVisitor to CDN (shared pages); Visitor to Render server (per-query, per-user); Regeneration to CDN (new shell); Render server to Seats and orders API (live data)VisitorVisitorCDN: landing, help, event shellsCDNlanding, help,event shellsRender server: search, checkoutRender serversearch, checkoutRegeneration: on organiser editRegenerationon organiser editSeats and orders API: no-storeSeats andorders APIno-storeshared pagesper-query, per-usernew shelllive data

    How to read this: the top row is produced ahead of time or in the background, the bottom row per request. Seats and orders are never cached in the shell.

    The other side of this topic. On the server, the same trade is made with caches and TTLs: see Caching Basics and CDN and Object Storage.

    Trade-offs

    Strategy Pick it when It costs If the data source fails
    CSR Behind login, highly interactive A JavaScript-built first paint, more JS Shell loads, content errors
    SSR Per request or per user, live data Origin work per request, higher TTFB The page fails or shows an error
    SSG Listable, rarely changing Build time per URL, staleness Files keep serving
    ISR / cached fragment Many pages, known change events Staleness window, cache behaviour to learn Last good page kept (documented for failed regeneration)
    Streaming shell + holes Mostly shared page with a live or personal part Boundary design, committed status code Shell serves, a hole shows its error boundary

    When the choice was wrong

    • Slow first byte on every page view: a request-time render where a cached page would do.
    • Customers see old prices: a budget longer than the real one, or a missing invalidation.
    • Builds take longer with every release: SSG on an unbounded set. Move to ISR with on-demand generation.
    • Origin load jumps after a harmless change: something made a cached route dynamic.

    In practice: Gigline's on-sale spike

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    Event pages are rendered per request, including price and seats. When a popular on-sale opens, many visitors request one page within a minute (illustrative) and the origin slows. Page views are mostly anonymous.

    Starting architecture

    One render server fetches the event, venue and seats on every request and returns full HTML. There is no shared page cache.

    Options considered

    Option What it fixes Cost Risk
    More origin capacity Load Money, repeated each spike Still renders the same page repeatedly
    ISR for the whole page Load Low Stale seat counts
    ISR shell plus live seats Load and freshness Splitting the page More moving parts

    The decision

    ISR shell with on-demand revalidation on organiser edits and a time-based window as a safety net (illustrative: 1 hour). Seats and price come from a small no-store fetch behind a boundary, and the hero image sits outside it.

    Rollout and verification

    Ship one event category first. Compare origin requests per page view before and after, check the cache-status header on repeat requests, and confirm that after an edit the page updates once invalidation is called.

    The 2 a.m. failure

    A later change adds a currency preference by reading a cookie in the shared event layout. The Next.js docs describe the previous caching model as one where such a read opts the whole route into dynamic rendering, so event pages quietly become per-request again and the next on-sale saturates the origin. First three checks: the cache-status header on repeat requests, how your framework reports the route as static or dynamic, and recent changes that read cookies() or headers() in a layout. Mitigation: revert the read. Follow-up: read the cookie in a small component behind its own boundary, and add a check that fails when an event route turns dynamic.

    Architect's notes

    • State the budget as a number, with the person who accepts it, before choosing.
    • Prefer holes to dynamic pages. A small live part is cheaper than a live page.
    • Review every cookies(), headers() and searchParams read like a change to caching, because it is one.
    • Watch the framework's mode. Names and defaults change between versions, so write down which model you are on.
    • Measure before moving. web.dev's advice is to understand your bottlenecks first and to consider whether static or server rendering gets you most of the way.

    Common mistakes

    • One strategy for the whole site because it is the framework default.
    • A freshness budget nobody agreed, so "real time" becomes the unstated requirement.
    • Pre-rendering an unbounded set such as search queries.
    • Personal data in a shared cache.
    • Lazy-loading or suspending the LCP element.
    • Ignoring failure behaviour: SSR fails with its data source, static does not.

    Key takeaways

    • Choose per page, then per part: a shared shell plus holes.
    • Order the questions: indexing and login, then variation per visitor, then freshness, then URL count, then failure behaviour.
    • Pick the most static option that meets the freshness budget.
    • ISR can serve a stale page once after the window; budget for it and invalidate on known changes.
    • A request-time read in the wrong place can make a whole route dynamic.
    • Streaming commits the status code early: check existence before the boundary.

    Practice exercises

    Exercise 1 - Starter (30 min): For your own product, list eight pages and fill in shared, budget and URL count for each. Write the strategy.

    Exercise 2 - Intermediate (1 hour): Take one page that you marked fully dynamic and split it into a shell and holes. Say what each part's lifetime is.

    Exercise 3 - Intermediate (2 hours): Using a framework that supports ISR, set a short revalidate window on a test page, change the source, and note how many requests pass before the new page appears.

    Exercise 4 - Advanced (half day): Write a one-page decision record for a page where two strategies are close. Include the budget, the failure behaviour and the metric you would watch.

    In this course:

    Elsewhere on the site:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments