I built all five web rendering strategies from scratch using only Node.js's http module. SSG bakes HTML at build time, SSR renders per request, CSR sends an empty shell to the browser, ISR adds a staleness clock to SSG, and PPR combines build-time and request-time rendering on the same page. Rendering strategies are about when and where you call the same render function, not about writing different versions of it.
There's a version of this post that's just a table comparing four acronyms with checkmarks under "SEO" and "Performance." You've seen it. Everyone has. It doesn't teach you anything you can actually use.
This is a different kind of post. I built all four rendering strategies from scratch, in the same project, sharing the same templates and data layer, using nothing but Node.js's built-in http module. No Next.js, no Express, no framework magic hiding what's actually happening. And the thing I kept running into was this: the implementations are almost embarrassingly simple. The concepts are not. So let's talk about both.
The full source is at github.com/atharvdange618/rendering-strategies.
Before we get into any code, you need the right mental model. Every rendering strategy is just an answer to one question:
Where and when does HTML get generated?
Everything else - the performance characteristics, the SEO tradeoffs, the infrastructure requirements - flows from that answer. Once you have it, the four strategies stop feeling like a grab bag of acronyms and start feeling like a spectrum of deliberate choices.
CSR moves HTML generation into the browser entirely. SSG and SSR both generate on the server but at completely different times. ISR sits between SSG and SSR, using staleness as the trigger for regeneration. Once you see them arranged this way, the tradeoffs follow naturally.
The key architectural decision in this project was building a shared template layer that all four strategies call into. One templates.js file, four strategies that invoke it at different times and in different contexts.
templates.js exports a renderPostList(posts) function. It takes an array of posts and returns an HTML string. It has no knowledge of what strategy is calling it, no side effects, no I/O. It's a pure function.
The strategy is not about what you render. It's about when you call the render function. The same renderPostList(posts) call appears in build.js (SSG), inside the request handler (SSR), inside the background job (ISR), and its equivalent lives in client/app.js as DOM node construction (CSR). Four strategies, one template, four different call sites.
Static Site Generation is the simplest strategy to understand and implement. You run a script before any users arrive, it generates HTML files, and those files sit on disk until you regenerate them.
Think of a bakery that preps everything the night before opening. When the first customer walks in at 7am, you don't start mixing flour. You just hand them a loaf that's already done. The tradeoff is clear: if the recipe changes after the loaves are baked, customers get yesterday's recipe until you bake again.
The build script (src/build.js) is the entire SSG implementation. The server handler (src/strategies/ssg.js) is almost nothing: read a file, send it. Notice the handler doesn't import posts data and doesn't call renderPostList(). All that template logic already ran during the build. The request handler is just a file server with a helpful error message for when the build hasn't been run yet (which is the only reason it imports htmlShell from templates - to render a "build required" page).
The "frozen timestamp" is the observable proof of SSG. Every page has a visible "HTML generated at" timestamp. With SSG, that timestamp doesn't change no matter how many times you refresh. It only changes when you run node src/build.js again.
The real-world implication: SSG sites can be served entirely from a CDN with no application server. Netlify, GitHub Pages, Vercel's static hosting: they're all just very fast file servers. If your server goes down, the files still serve. That's resilience you get for free.
Server-Side Rendering moves HTML generation from build time into the request handler. Every time a user hits your server, the server runs your template logic, builds fresh HTML, and sends it.
Back to the bakery analogy: SSR is made-to-order. The customer arrives, you cook fresh. Better quality, guaranteed freshness, but the kitchen is doing work for every single customer. Scale that to 10,000 simultaneous customers and you understand why SSR servers need to be sized for traffic.
The SSR handler is actually shorter than the SSG handler because there's no error case for "file not found." The data is always available, and the render always runs.
I simulated three back-to-back requests in about 25ms total, and all three had different timestamps to the millisecond. The function re-ran three times. That's both the power and the cost of SSR. Power: absolute freshness. Cost: you're paying compute on every request, even when nothing has changed.
The Cache-Control: no-cache, no-store, must-revalidate header is important. If you cache an SSR response at the browser level, you've accidentally built SSG with extra steps and worse performance. Don't cache SSR responses unless you've intentionally layered a caching strategy on top (which is basically what ISR is).
Client-Side Rendering flips the entire model. The server sends almost no HTML. Instead it sends a JavaScript file. That file runs in the browser, fetches data from a JSON API, and builds the DOM directly. The server is not in the rendering business at all.
This is the model that React made mainstream. Your browser is doing the work your server used to do.
Two HTTP requests for one page load. That's the key cost of CSR. The first gets the shell, the second gets the data. Content cannot appear until both complete.
The most visceral way to experience this: open /csr, right-click, View Page Source. You'll see an empty <div id="app"> with a comment inside it. No posts, no titles, no content at all. Now open DevTools, go to Elements. The posts are there. That difference between View Source and Inspect is CSR in one observation. View Source shows what the server sent. Inspect shows what JavaScript built.
The el() function here is a stripped-down version of the h() hyperscript helper from my Virtual DOM project. SSG, SSR, and ISR produce HTML strings on the server. CSR builds DOM nodes in the browser. Same conceptual template, different output type, different execution environment.
The SEO implication is real. When Googlebot fetches your CSR page, it gets an empty shell. Google does run JavaScript, but it's slower and less reliable than reading HTML directly. If your content needs to be indexed, CSR alone is not enough.
Incremental Static Regeneration is not a fundamentally different rendering approach. It's SSG with a staleness clock. You start with a static file, but you define a revalidation window. After that window expires, the next request gets the stale file immediately, and a background job regenerates the cache for the request after that.
If you've seen the stale-while-revalidate HTTP Cache-Control directive, this is that pattern implemented in application logic rather than browser cache headers.
That diagram is the whole thing. Request D is the "sacrificial" request that pays the revalidation trigger cost (but not the rebuild cost, since the rebuild is async). Request E reaps the benefit.
The implementation has four distinct states:
The line ordering in State 3 is the entire ISR mechanic. res.end() fires before revalidateInBackground(). The user's response is sent, then the rebuild happens. If you reverse that order, you've built SSR: the user waits for the rebuild to finish. The await being absent on revalidateInBackground is not an oversight, it's the implementation.
One subtle thing to get right: cache state needs to persist across requests. In Node.js, module-level variables are singletons within a process. The isr-cache.js module holds the lastGeneratedAt timestamp at module scope, meaning all requests share that value. It also syncs from disk on startup via fs.statSync, so a server restart doesn't lose track of an existing cache file.
I want to return to templates.js because it's doing something conceptually important.
This function is called by:
build.js at build time (SSG)strategies/ssr.js inside the request handler (SSR)isr-cache.js inside the background job (ISR)client/app.js in the browser, producing DOM nodes instead of HTML strings (CSR)Strip out the strategy-specific values (timestamp, badge color, page title) and SSG and SSR produce byte-for-byte identical HTML from the same posts data. I verified this by normalizing both outputs and running a strict equality check. They matched. One character difference, just the title string length.
This is what Next.js, Nuxt, and SvelteKit are actually doing when they let you use the same component for SSG, SSR, and CSR. The component is a pure function. The framework decides when and where to call it.
The comparison tables online give you checkmarks. Building the code gives you something better: intuition for why each tradeoff exists.
A few things pop out from this table that aren't obvious from just reading about the strategies:
SSG and ISR have the same server cost per request. The difference is entirely in the regeneration trigger: SSG is manual (you run the build script), ISR is automatic (time-based window). If you're trying to choose between them, ask whether your data changes frequently enough to justify the background revalidation infrastructure.
CSR has near-zero server cost per page request, but it trades that for a second HTTP request (the API call) and a blank page window while JavaScript loads. On a 4G connection this might be 100ms. On a slow device with a large JS bundle it might be 2-3 seconds. The server wins, the user potentially loses.
SSR is the only strategy that is always fresh at zero extra complexity. You pay for that with compute per request, which means horizontal scaling instead of CDN distribution.
One design decision in this project that I'd recommend to anyone building a similar learning project: every rendered page displays a "HTML generated at" timestamp, and that timestamp is baked into the HTML itself.
This makes the behavior of each strategy immediately observable without needing to understand the code. SSG's timestamp freezes until you rebuild. SSR's timestamp changes on every refresh. ISR's timestamp stays the same within the window, then updates on the request after a stale one. CSR has two timestamps: when the shell was sent, and when the API response came back.
You could describe all of this in words. But staring at two browser tabs side by side, one SSG and one SSR, refreshing them repeatedly and watching one timestamp change while the other stays frozen, that teaches it faster than any explanation.
After finishing all four strategies, I got asked about Partial Pre-Rendering in a mock interview and had nothing to say. I'd never heard of it. So naturally I went and built it too.
PPR is a newer idea, first shipped experimentally in Next.js 14. It's not widely talked about yet, which is why it catches people off guard. But once you've built SSG, SSR, CSR, and ISR, you're actually in the best position to understand it - because PPR is a direct response to a problem none of the four strategies solve cleanly.
Every strategy we built applies uniformly to an entire page. SSG makes the whole page static. SSR makes the whole page dynamic. ISR gives the whole page one revalidation window. CSR puts the whole page in the browser.
But real pages aren't uniform. Take the blog post listing page we built. The post list itself - titles, excerpts, authors, dates - never changes unless you publish something new. That content would love to be SSG. Pre-render it once, stick it on a CDN, zero server compute per request.
But imagine the same page also shows a "Trending this week" widget with live view counts. That's inherently request-time data. It changes constantly. It can't be pre-rendered.
So you're forced to choose. Make the whole page SSR because of one dynamic widget, and now you're paying per-request render cost for content that hasn't changed in days. Or make it SSG and accept that the trending widget is always stale.
PPR's answer: stop treating the rendering strategy as a page-level decision. The post list is static. The trending widget is a dynamic hole. Pre-render the former at build time, fill the latter at request time, and deliver both in a single HTTP response.
The updated mental model now has a fifth entry:
When I first started discussing about PPR with a friend, while we were discussing, a thought flashed in mind: "Wait, isn't that just Astro's Island architecture?" Like it's an understandable comparison. Both architectures reject the dogmatic view that a webpage must use a single, uniform rendering mode from top to bottom. However, they operate on entirely different dimensions. Conflating them confuses the distinct engineering problems they are trying to solve.
Astro Islands split along the client axis: static HTML that ships zero JavaScript vs interactive components (islands) that need JavaScript to function. The question Astro answers is: "does this component need JS?" A carousel needs JS. An article body doesn't. Astro ships HTML for the article and a JavaScript bundle only for the carousel.
PPR splits along the server timing axis: content that can be pre-rendered at build time vs content that requires data available only at request time. The question PPR answers is: "does this section need fresh data?" A post list doesn't (publish rarely). A view counter does (updates constantly). PPR pre-renders the post list and fetches the counter live.
Astro Islands: "does this need JavaScript?"
PPR: "does this need fresh server data?"
They're orthogonal questions. A page could need both - and in fact Next.js's implementation of PPR with React Server Components essentially combines them. The Server Component renders HTML (PPR hole), and if it contains a Client Component, that Client Component hydrates in the browser (island behavior). React Server Components is the framework-level unification of both patterns. But conceptually, they start as separate concerns.
This is where it got interesting. I didn't get PPR right the first time, or the second. Each failed attempt taught something specific.
The first implementation looked like this: the request handler called buildStaticShell(posts, generatedAt) to generate the HTML for chunk 1, wrote it with res.write(), did the async fetch, then wrote the dynamic chunk with res.end().
This was wrong in a fundamental way. buildStaticShell() was being called inside the request handler. That means on every single request, the server was running the full template logic - reading data, building the post list, assembling the HTML. The timestamp in the "static" shell banner showed the request arrival time, not a build time.
That's not PPR. That's streaming SSR. I'd added chunked transfer encoding and a two-phase response, but I hadn't actually pre-built anything. The name said "static shell" but the behavior was entirely dynamic. The streaming mechanic was real. The "pre-built" claim was false.
The learning: if you've actually pre-rendered something, the server handler should have no need to import your template functions. It should just read a file. My v1 handler was importing buildStaticShell, which meant it was doing rendering work. That's the wrong contract.
But there was a new problem. Every other file we'd written to disk - ssg.html, isr.html - was a complete HTML document with proper closing tags. The PPR shell couldn't be complete, because we needed to stream dynamic content after it. So the solution I reached for was to intentionally write a broken document - no </body>, no </html> - and rely on the browser's lenient parsing to handle it.
This worked. Browsers handle open documents gracefully during streaming. But it felt wrong because it was wrong. You're exploiting browser leniency, not doing something intentional. If I opened this file directly in a browser without the server streaming anything after it, it would render in an unclosed state. The file on disk wasn't a real HTML document.
More importantly, it raised an obvious question: is there a way to do this with a complete, valid HTML document? No half-open hacks?
This came from someone asking a sharp question: why can't we just send the full, complete HTML with the placeholder elements, and then in the next chunk send the JavaScript that fills in the holes?
The proposal was to use <script src="resolver.js"> in the shell and send the script file as chunk 2. That won't work - <script src=""> fires a new HTTP request entirely separate from the current stream. The browser doesn't look at the next bytes in the current response to fulfill it.
But the instinct was right. The fix is <script>inline code here</script> instead of <script src="">. Inline scripts are read directly from the current response stream. And here's the part that isn't immediately obvious: browsers execute inline <script> tags that appear after </html>. Browsers parse the entire HTTP response stream progressively, not just up to the closing tag. Content after </html> is handled gracefully. This is intentional, spec-compliant behavior.
So the v3 approach:
The shell defines window.__pprResolve as a global function before </body></html>. That function is available the moment the browser parses the shell. When chunk 2 arrives - an inline script after the document's closing tags - the browser executes it, it calls __pprResolve(), and the skeleton is replaced with real content.
Complete, valid HTML on disk. Real file you can open in a browser standalone. No broken document structure. The dynamic injection happens via the stream, using a function that was pre-registered in the shell.
Then it clicked: this is exactly what React's streaming SSR does. When a Suspense boundary resolves, React injects <template> tags and inline <script> calls to $RC() (its internal replacer function) into the response stream, often appearing after </html>. We independently arrived at the same mechanism.
After implementing v3, the question came up: what exactly is the difference between PPR and streaming SSR now? They both use chunked transfer. They both deliver content in two moments. They both use inline scripts to swap placeholders. The code looks structurally similar.
The difference is not in the delivery mechanism. It's in the origin of the first chunk.
With streaming SSR, every single request triggers the full render pipeline for the static portions. The server reads data, runs template functions, builds HTML strings - for every request, even when the output is identical to the last thousand requests. The streaming is an optimization in how you deliver work that's being done right now. Like a chef who sends your starter while your main is still on the stove - both items are being cooked for your specific order, right now.
With PPR, the static shell was computed once, at deploy time, and written to disk. When a request arrives, renderPostList() is never called. htmlShell() is never called. The handler does a file read - the same operation SSG's handler does - and forwards those bytes. The chef grabbed a bread basket that was baked this morning off the shelf and handed it to you instantly. The stove only turns on for the one item that needs to be made fresh.
The test that makes this concrete: if you removed your origin server entirely and left only a file server, what would happen?
Streaming SSR breaks completely. Every request requires the origin to run the template pipeline.
PPR gracefully degrades. The static shell - the post list, the nav, the styles - still serves correctly from disk. Users see the skeleton placeholder instead of the trending widget, but the vast majority of the page still works. The static portion is independent of the application server.
That independence is what "pre-rendered" means in the PPR context. The application server doesn't need to exist for it to be served.
In production Next.js, the separation is even more explicit. The pre-built shell lives on a CDN edge node physically close to the user. The first chunk doesn't even touch your origin server - it streams from the edge immediately. The origin server only handles the dynamic holes. Our fs.readFileSync approximates the CDN step: it's serving pre-built content without computation, just like an edge node would.
Adding PPR to the comparison table:
PPR is the only strategy with entries in both the "build time" and "every request" columns. That's the whole point. Different sections of the same page have different answers.
The half-open HTML temptation. v2's broken document actually worked in every browser I tested. Chrome, Firefox, Safari - all handled it fine. That's the danger. Something can work and still be wrong. The question isn't whether the browser tolerates it, it's whether the artifact on disk is correct. A file that can't be opened standalone without being in a broken state is a bad file, even if the downstream consumer is lenient about it.
Script delivery confusion. The instinct to use <script src="resolver.js"> is natural. External script references are how you normally load JS. Understanding why it fails here - that src="" fires an entirely new HTTP request, separate from the current stream - requires knowing how the browser's resource loader works independently of the HTML parser. These are different subsystems. The HTML parser reads the stream. The resource loader makes new requests. Inline scripts are read by the parser from the same stream. That distinction matters for PPR.
The streaming SSR confusion was productive. The question "isn't this just streaming SSR now?" initially felt like a gotcha. It turned out to be the most clarifying question of the whole project. Answering it precisely - the origin of the first chunk, the file-server independence test, the per-request compute comparison - produced clearer understanding of PPR than any article I read about it.
Timestamps as a debugging tool paid off. The "HTML generated at" timestamp that every page shows was added for educational reasons. It ended up being the fastest way to verify each implementation was actually correct. The PPR shell's frozen build timestamp vs the dynamic section's per-request timestamp made the two-phase nature immediately visible. Recommend this for any rendering project.
SSG bakes HTML at build time. Update means rebuild.
CSR sends an empty shell. The browser fetches data and builds the DOM. Two HTTP requests per page load.
ISR is SSG with a staleness clock. The user who triggers revalidation gets the stale page; the next user gets fresh content.
PPR is the only strategy that applies different rendering modes to different sections of the same page.
The shared template pattern is the real architectural takeaway. Rendering strategies are about when and where you call your render function, not about writing different versions of it.
If your "static shell" handler imports template functions and generates HTML at runtime, you haven't pre-rendered anything. The tell is the imports.
The file-server independence test distinguishes streaming SSR from PPR: if you turned off the origin server, would the static portions still serve? SSR: no. PPR: yes.
Hydration - the combination of SSR and CSR - is what Next.js and Nuxt ship in production. The server sends full HTML, then the browser's JavaScript takes over the existing DOM and makes it interactive. The cost: shipping both the server-rendered HTML and the client-side JavaScript that describes the same UI.
Related posts based on tags, category, and projects
Node.js is a carefully assembled runtime, not just 'JavaScript on the server.' V8 compiles JS to machine code, libuv provides the event loop and thread pool for async I/O, and the C++ bindings connect them. Understanding this architecture explains why Node.js handles concurrency with a single thread, when worker threads are actually needed, and how non-blocking I/O really works under the hood.
Middleware is code that runs between a request arriving and a response being sent. In Express, every middleware function in the chain gets a chance to inspect, modify, or stop a request. This post covers what middleware is, how `next()` controls the flow, and where it gets used in real applications.
Node.js isn't fast because of raw processing power. It's fast because it never waits around when there's work to do. This post covers the architectural decisions that make Node.js well-suited for high-concurrency web applications.
REST is a set of conventions for designing APIs that are predictable, consistent, and easy to work with. This post covers what REST means, how it maps to HTTP, and how to build a clean resource-based API in Express.
WHO generates the HTML?
│
┌────────────┴────────────┐
│ │
SERVER BROWSER
│ │
WHEN? CSR
│
┌─────────┴──────────┐
│ │
BUILD TIME REQUEST TIME
│ │
SSG SSR
│
+ STALENESS WINDOW?
│
ISRrendering-strategies/
├── data/
│ └── posts.json ← single source of truth
├── dist/ ← SSG and ISR write pre-built files here
│ ├── ssg.html
│ └── isr.html
├── client/
│ └── app.js ← runs in the browser for CSR
└── src/
├── server.js ← HTTP server, routes all four strategies
├── build.js ← SSG build script, run manually
├── templates.js ← SHARED: renderPostList(), htmlShell()
├── isr-cache.js ← ISR cache state + revalidation logic
└── strategies/
├── ssg.js ← reads dist/ssg.html, serves it
├── ssr.js ← generates HTML per request, serves it
├── csr.js ← serves empty shell + script tag
└── isr.js ← ISR state machineBUILD TIME (before any users arrive)
──────────────────────────────────────────────────────
posts.json ──► renderPostList() ──► ssg.html (disk)
──────────────────────────────────────────────────────
REQUEST TIME (when user visits /ssg)
──────────────────────────────────────────────────────
User ──► Server reads ssg.html ──► User gets HTML
(No template logic. No data fetching. Just a file read.)
──────────────────────────────────────────────────────// The ENTIRE SSG request handler
const fs = require("fs");
const path = require("path");
const { htmlShell } = require("../templates");
const DIST_PATH = path.join(__dirname, "../../dist/ssg.html");
module.exports = function handleSSG(req, res) {
fs.readFile(DIST_PATH, "utf-8", (err, html) => {
if (err) {
// Build hasn't been run yet - show a helpful error
const errorHtml = htmlShell({
title: "SSG - Build Required",
body: `<h1>Build not found</h1><p>Run <code>node src/build.js</code> first.</p>`,
strategy: "SSG",
generatedAt: new Date().toISOString(),
});
res.writeHead(404, { "Content-Type": "text/html" });
res.end(errorHtml);
return;
}
res.writeHead(200, {
"Content-Type": "text/html",
"Cache-Control": "no-cache",
});
res.end(html);
});
};REQUEST TIME (runs on EVERY request)
──────────────────────────────────────────────────────
User hits /ssr
│
▼
posts.json ──► renderPostList() ──► HTML string ──► User
(Same template call as SSG build.
Different timing: per-request, not per-build.)
──────────────────────────────────────────────────────module.exports = function handleSSR(req, res) {
const posts = JSON.parse(fs.readFileSync(DATA_PATH, "utf-8"));
const generatedAt = new Date().toISOString();
const html = htmlShell({
title: "Blog Posts - SSR",
body: renderPostList(posts),
strategy: "SSR",
generatedAt, // changes on every single request
});
res.writeHead(200, {
"Cache-Control": "no-cache, no-store, must-revalidate",
});
res.end(html);
};Server side Browser side
───────────────── ──────────────────────────────────
User hits /csr
│
▼
Server sends HTML shell HTML arrives (nearly empty)
<html> │
<div id="app"></div> ─────────► │ Script tag fires (defer)
<script src="/client/app.js" defer> │
</html> ▼
Fetch /api/posts
│◄───────────────────────────────── │ (second HTTP request)
│ JSON: { posts: [...] } │
├──────────────────────────────────►│
▼
Build DOM nodes
Insert into #app
User sees content// client/app.js - runs in the browser, not Node.js
async function main() {
const response = await fetch("/api/posts");
const { posts } = await response.json();
// Build DOM nodes manually (what React does via VDOM + reconciliation)
const list = el(
"ul",
{ className: "post-list" },
...posts.map(renderPostCard),
);
document.getElementById("app").appendChild(list);
}Timeline (revalidation window = 10s)
─────────────────────────────────────────────────────
t=0s Build runs, dist/isr.html created
│
t=2s Request A ──► "fresh" ──► served instantly from cache
t=5s Request B ──► "fresh" ──► served instantly from cache
t=9s Request C ──► "fresh" ──► served instantly from cache
│
t=10s Window expires
│
t=12s Request D ──► "STALE" ──► served instantly (old file)
│ └── triggers background regen (async, non-blocking)
│
t=12s+ε Background job runs: read data, render, write new file to disk
│
t=14s Request E ──► "fresh" ──► served instantly (new file)
─────────────────────────────────────────────────────
Request D gets stale content.
Request E gets fresh content.
Neither request WAITS for the other's work.module.exports = async function handleISR(req, res, posts) {
const cachedHtml = readCache();
// State 1: No cache exists yet (cold start)
if (!cachedHtml) {
const html = await generateAndCache(posts); // wait, user waits too
res.end(html);
return;
}
// State 2: Cache is fresh (within window)
if (isCacheValid()) {
res.end(cachedHtml); // instant serve, nothing else
return;
}
// State 3 & 4: Cache is stale - serve it NOW, revalidate AFTER
res.end(cachedHtml); // ← response sent first
revalidateInBackground(posts); // ← THEN background work starts
// ^ no await. intentional.
};// templates.js
function renderPostList(posts) {
if (!posts || posts.length === 0) {
return `<p style="color: #64748b;">No posts found.</p>`;
}
const items = posts
.map((post) => {
const tags = (post.tags || [])
.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`)
.join(" ");
return `
<li class="post-card">
<div class="post-title">${escapeHtml(post.title)}</div>
<div class="post-excerpt">${escapeHtml(post.excerpt)}</div>
<div class="post-meta">
<span>${escapeHtml(post.author)}</span>
<span>${escapeHtml(post.date)}</span>
<span>${tags}</span>
</div>
</li>`;
})
.join("\n");
return `
<h1>Blog Posts</h1>
<p class="subtitle">${posts.length} articles on JavaScript, architecture, and building things.</p>
<ul class="post-list">
${items}
</ul>`;
}Strategy Where rendered When rendered Server cost/req Freshness
─────────────────────────────────────────────────────────────────────────────
SSG Server Build time ~0 (file read) Stale until rebuild
SSR Server Every request Render + data Always fresh
CSR Browser After JS runs ~0 (shell send) Fresh (per fetch)
ISR Server Build + background ~0 (file read)* Stale until reval
─────────────────────────────────────────────────────────────────────────────
* except cold start and revalPPR page anatomy
─────────────────────────────────────────────────────
┌─────────────────────────────────────────┐
│ Nav, header │ ← pre-built at build time
│ Blog post list (5 posts) │ ← pre-built at build time
│ ┌─────────────────────────────────┐ │
│ │ [shimmer skeleton] │ │ ← placeholder, pre-built
│ │ → fills with: Trending widget │ │ ← resolved at request time
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
Delivery: one HTTP connection
Chunk 1 (instant, from disk): everything above the skeleton
Chunk 2 (~150ms later): the resolved trending data
───────────────────────────────────────────────────── WHO generates the HTML?
│
┌────────────┴────────────┐
│ │
SERVER BROWSER
│ │
WHEN? CSR
│
┌─────────┴──────────┐
│ │
BUILD TIME REQUEST TIME
│ │
SSG SSR
│ │
ISR ONLY THE DYNAMIC HOLES
│ │
└─────────┬──────────┘
│
PPR
(both, on the same page,
in the same response) Static Dynamic
(pre-built) (request-time)
┌──────────────────────────────────────┐
Server-side │ SSG / PPR shell │ SSR / PPR hole │ ← PPR lives here
├──────────────────────────────────────┤
Client-side │ Static HTML │ JS Islands │ ← Astro Islands here
└──────────────────────────────────────┘dist/ppr-shell.html (v2)
─────────────────────────────────────
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<nav>...</nav>
<ul class="post-list">...</ul>
<div id="dynamic-hole">
[shimmer skeleton]
</div>
<!-- document intentionally left open -->
← NO </body> NO </html>
─────────────────────────────────────dist/ppr-shell.html (v3)
─────────────────────────────────────
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<div id="dynamic-hole">[skeleton]</div>
<script>
// contract between shell and dynamic chunk
window.__pprResolve = function(id, html) {
document.getElementById(id).innerHTML = html;
};
</script>
</body>
</html> ← PROPER CLOSING TAGS. Real document.
─────────────────────────────────────
Chunk 2 (streamed after async fetch, appended after </html>):
─────────────────────────────────────
<script>
window.__pprResolve('dynamic-hole', '<div class="trending-widget">...</div>');
</script>
─────────────────────────────────────Streaming SSR - per request:
request → origin server
│
├── read data
├── render post list ← happens every request
├── build HTML string ← happens every request
│
├── res.write(chunk1)
├── await dynamicFetch()
└── res.end(chunk2)
PPR - per request:
request → read ppr-shell.html ← disk read, no compute
│
├── res.write(shellHtml)
├── await dynamicFetch() ← only this is per-request compute
└── res.end(resolverScript)Strategy Where rendered When (static) When (dynamic) Server cost/req
──────────────────────────────────────────────────────────────────────────────
SSG Server Build time - ~0 (file read)
SSR Server - Every request Render + data
CSR Browser - After JS runs ~0 (shell send)
ISR Server Build time Background reval ~0 (file read)*
PPR Server (both) Build time Every request ~0 + dynamic only
──────────────────────────────────────────────────────────────────────────────
* except cold start and reval