System Design

Decide Rendering Per Route

mediumarchitecture

Decide Rendering Per Route

Where the HTML gets built, when it gets built, and why the answer is different for every route in the same application.

You have shipped this decision without making it. npm create vite put the rendering in the browser. next build on a page with no data put it on the build machine. Nobody wrote "rendering strategy" in a design doc; the framework's default answered the question, and the answer stayed.

A rendering strategy is two questions, not one. Where the HTML gets built: the build machine, a CDN edge node, your origin server, or the customer's browser. When it gets built: before anyone asks, at the moment they ask, or after the page has already appeared. Pick a where and a when and you have named a strategy. Every acronym in this article is one of those pairs with a marketing name attached.

We will design one page and let each decision fall out of it: the product listing page for an online store. A grid of products, each with a price and a stock badge, and above the grid a strip of recommendations picked for the customer looking at it. It is the page almost every store has, and it wants three things that do not get along.

Three things that fight

  • Fast. Something readable in the first response, before a single byte of JavaScript is parsed. The product names are the page; an empty grey rectangle is not.
  • Fresh. The price and the stock badge have to be true at the moment of the view. A sold-out product shown as in stock is not a rendering bug, it is a support ticket and a refund.
  • Personal. The recommendation strip is different for every customer, which means the HTML is different for every customer, which means no shared copy of it exists anywhere.

Any two of these are easy. Fast and fresh is a server render. Fast and personal is a static page with the personal part fetched afterwards. Fresh and personal is what every screen behind a login already does. All three at once, in the first response, is the entire subject.

Where the HTML gets built

Four machines can produce your HTML, and each one can only work at certain moments. That grid of machine against moment is the whole decision space; the named strategies are just the cells people found useful enough to label.

Every strategy is one machine plus one moment. Move left and the page gets cheaper and staler; move right and it gets fresher and slower to appear.
StrategyHTML builtFreshnessCost per requestGood for
Client-renderedIn the browser, after the JavaScript loadsCurrent, once the fetch returnsA file from a CDN; no server at allScreens behind a login: dashboards, consoles, editors
StaticOn the build machine, before anyone asksAs of the last buildA file from a CDNMarketing pages, docs, help centre
Static + revalidateOn the build machine, then rebuilt in the backgroundStale by up to the revalidate windowA file from a CDN, plus one rebuild per windowCatalogues that change hourly, not per view
Server-renderedOn the origin or the edge, per requestCurrentA full render, every visitor, every navigationPages that depend on who is asking
Streaming SSROn the origin, per request, sent in piecesCurrentThe same render, spread over timePages where one slow query blocks a fast page

For the product grid the answer lives in the last two rows. The page depends on who is asking, so nothing can be built ahead of time; and once you are rendering per request, streaming is how you stop the slowest query from holding the whole page hostage. The rest of this article is why the first three rows lost, and where each of them wins instead.

Rendering it in the browser

The browser receives <div id="root"></div> and instructions to go and fetch the rest. Nothing about that is parallel: it needs the HTML before it learns about the JavaScript, the JavaScript before it learns about the data, and the data before it can print a single product name. Four serial round trips before anything readable, and every one of them finishes on hardware you do not control. A five-year-old Android on hotel Wi-Fi runs your render, not your server.

That is the wrong shape for a storefront and exactly the right shape for the screens behind the login. An admin console, a trading dashboard, an internal editor: the customer signs in once, keeps the tab open all day, and moves between forty screens that share one shell. Nobody is measuring the first paint of an internal tool, and search engines were never invited. Client rendering is not slow. It is slow once, and fast forever after, which is a good trade only when the customer stays.

Building it before anyone asks

The build machine runs the render once, writes an HTML file, and a CDN hands out copies. There is no server in the request path, nothing to scale, nothing to wake up, and no database that feels your traffic. For the store's marketing pages and its help centre this is the correct answer and it is not close.

The catch is that the file is a photograph of the data at build time. Ship a price change and the page keeps the old price until something rebuilds it. And a catalogue of 200,000 products cannot be photographed in a CI job that has to finish before anyone goes home. So the escape is to build the pages that actually get traffic, render the rest on first request, keep the result, and set a window after which it gets rebuilt in the background:

// app/products/[id]/page.tsx
export const revalidate = 60; // seconds

That line is not a performance setting. It is you deciding, out loud, how wrong the page is allowed to be. Sixty seconds is nothing for a description and a photograph. It is far too long for "2 left in stock." The usual resolution is to let the slow-moving parts go stale and read the one number that cannot on every view, which is the same instinct that ends up producing the streaming answer below.

Building it per request

Server rendering runs the render on your machine, on every request, and sends finished HTML. The customer's first paint no longer waits for a JavaScript bundle, and the page is as fresh as the query behind it. What people forget is that this moves the work rather than deleting it: time to first byte now includes your database, and you own the capacity to do that render for every visitor, including the ones who would have been perfectly happy with a cached copy.

There is a sharper problem. The render is one function, so it finishes when its slowest await finishes. In our page the product grid is ready in 40ms and the recommendation strip takes 800ms. The customer waits 800ms and looks at nothing, on a page that was 95% ready almost immediately. Server rendering, done as one blocking pass, makes the whole page as slow as its worst dependency.

Not choosing

The way out is to stop treating the response as one document. HTTP has always allowed a response to be sent in pieces, and React's Suspense boundaries are how you say which pieces. You mark the slow part, React sends the rest of the page immediately with a placeholder in its position, and streams the real markup down the same open response when the data resolves.

The same page, the same data, three strategies. The tick marks when a product name is on screen; everything to the left of it is the customer looking at nothing. Streaming does not make the recommendation query faster — it stops that query from being the customer's problem.

In code the whole idea is one boundary drawn around the slow component. Note that the slow part sits at the top of the page and still does not block what comes after it; the stream is not obliged to arrive in document order.

export default function ProductsPage() {
  return (
    <Layout>
      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations />   {/* 800ms */}
      </Suspense>

      <ProductGrid />          {/* 40ms — ships with the shell */}
    </Layout>
  );
}

A Suspense boundary is a promise about layout: whatever is inside may arrive late, and the page has to look right without it. Draw the boundary where you can afford a skeleton, not where the code happens to split. And give the skeleton the height of the real thing, or you have traded a blank page for a page that jumps under the customer's thumb.

The skeleton is not a courtesy, it is a reservation. Because the strip claims its final height at 75ms, the grid below it lands once and stays put when the real recommendations arrive eight hundred milliseconds later.

What Server Components change

React Server Components are not a sixth strategy, and reading them as one is the most common way to get confused here. They answer a different question: not when the HTML is built, but how much JavaScript has to follow it down the wire. A product card that formats a price with a currency library can run that formatting on the server and never ship the library; what reaches the browser is finished markup plus a description of where the interactive parts go.

The strategy question is untouched. Those components still render either at build time or per request, and you still choose which. What changes is the bill for interactivity: everything is a Server Component until something needs to listen to the customer. Next.js's Partial Prerendering is where the two ideas meet — a static shell served from the CDN with dynamic holes streamed into it — which is the first serious attempt to get fast, fresh and personal in one response rather than picking two.

Picking per route

Here is the part that most answers miss. None of this is an application-level choice. One store has routes with completely different obligations, and a framework that makes you pick once for the whole app is a framework you will fight. Our store, route by route:

RouteStrategyBecause
/StaticMarketing copy that changes when someone publishes, not when someone visits
/products/[id]Static + revalidateDescriptions and photographs age slowly; the stock badge reads fresh on the client
/productsStreaming SSRPersonal, so nothing is shareable — and one 800ms query would otherwise block a 40ms grid
/search?q=Server-renderedUnbounded query space; there is nothing to prebuild and nothing worth storing
/account/ordersClient-renderedBehind a login, no search engine, and the shell is already loaded by the time you arrive
/checkoutServer-renderedPrices and totals must be computed by someone the customer cannot edit; never cached

The question is never "is this app server-rendered or client-rendered." It is "what does this route owe the person who opened it." The mistake I see most often is a team answering it once in month one, on the first page they built, and then never asking again as the product grows routes that want something else entirely.

What each choice costs you

Move rightward on that timeline and you take back work the CDN was doing for free:

  • A server in the request path. Static has no capacity problem because there is nothing to run. Anything per-request has instances, cold starts, and a database that now feels every page view — including from crawlers.
  • Cache keys become a design problem. The moment the document depends on who is asking, no two customers can share a copy, and the edge stops being a cache. This is the true price of putting the recommendation strip in the shell, and the reason it belongs behind its own boundary instead.
  • Streaming spends your status code early. Once the shell is flushed you have already sent 200 OK. A failure after that cannot become a 500 or a redirect; it has to be handled inside the boundary that failed, as UI.
  • Server HTML is a photograph until hydration. The buttons look ready before they are. A customer who taps "Add to cart" in that window gets nothing back, so either disable the control in the markup and enable it on hydration, or expect the ticket.
  • "Why is this page old" becomes a class of bug. The CDN, the framework's data cache, and the browser's back/forward cache each hold a copy, and any one of them can be the one still showing yesterday's price.
One personalized element changes the economics of the whole document. The edge can only share what is identical, so the recommendation strip does not just cost 800ms — it costs every cache hit the page would have had.

Why interviewers ask this

"Walk me through what happens when the page first loads" opens almost every frontend system design round, and this is what it is testing. It comes first because it has to: you cannot argue about caching, SEO, or perceived performance until you have said where the HTML comes from, so a wrong answer here quietly poisons the next thirty minutes.

The signal being marked is not whether you can name five strategies. It is whether you reason per route and say the trade-off out loud — this page is personal so nothing can be shared, this one is stale by up to a minute and that is fine, this one streams because one query is slow and the rest is not. For the deeper background, Rendering on the Web by Jason Miller and Addy Osmani is still the piece the whole industry's vocabulary comes from.

The rules that matter most

  1. 01Decide per route, not per app. One product is allowed to use four strategies, and any answer that picks one for everything has skipped the actual question.
  2. 02Static anything nobody personalizes. If two customers would see the same bytes, build it once and let a CDN do the rest.
  3. 03Name the staleness window out loud. A revalidate number is a product decision about how wrong a page may be, not a tuning knob.
  4. 04Stream when one dependency is slower than the rest. Send what is ready, reserve the space for what is not.
  5. 05Keep personalization behind its own boundary. The moment it reaches the shell, the document stops being cacheable for anyone.
  6. 06Leave the browser the screens behind the login. Slow first, fast forever is a good trade only where the customer stays.

Remember

Ask what the route owes the person who opened it. Serve static what nobody personalizes, stream the routes where one slow query would otherwise hold a readable page hostage, and leave the browser to render the screens behind a login, where nobody is measuring the first paint.

Timings in this article describe the worked example, not a benchmark — an 800ms recommendation query against a 40ms product grid. Substitute your own numbers; the shape of the argument does not change.