← Architecture
Frontend applications Where HTML is made

Server, static, or client rendering

Choose per route, and pay for what you choose.

SvelteKit asks whether a page is prerendered. Next.js asks what is cached and what streams. Astro asks which parts are islands. Each is the same question, asked per route, and an agent will happily answer it once for the whole site. Let’s build a small docs site and see what each answer costs, and who pays.

TypeScriptGoOne docs site, five plans, two recorded agent runs.

01 / The prompt

“Build a docs site that’s fast, searchable, and cheap to host.”

bramble is a small command-line tool. Its docs site has three pages: an install guide, a changelog, and an API keys page where you see your own keys. It sits behind a CDN. There are three answers an agent could give, and each is a real, defensible design:

  • Prerender everything. Static HTML files, served straight from the CDN. Fast and nearly free.
  • Render everything on the server, per request, perhaps with a CDN cache in front to keep it cheap.
  • Make it a single-page app. One HTML shell and a script that fetches and renders each page in the browser.

Every one of them works in a demo. Each fails on one of the three pages: the static keys page was built before anyone signed in, the single-page app hands search engines an empty shell, and the cached server render hands one reader’s keys to the next. The question the prompt never answered is that these are three different pages, and the choice belongs to each of them.

02 / Name the choice

Three answers, three bills.

A rendering mode is where and when a page’s HTML is made. Static means at build time, once, for everyone. Server means per request, for the person asking. Client means in the browser, after a script runs. Each one moves a cost somewhere: to the deploy, to the server, or to the reader’s device and connection.

Choose the mode per route, from two facts: does the page differ per reader, and does it change between deploys? Then say, in Cache-Control, who may share the result.

What each mode gives the docs site, and what it costs
StaticServerClient
Content in the first HTMLYesYesNo: a shell, then a second request
Knows who is readingNo: built with no requestYesYes, in the second request
Shows a change published after deployNot until the next deployAt once, or after the cache timeAt once
Server work per readerNoneOne render, unless a public cache shares itOne data request
Fits bramble’sInstall guideChangelog (shared), keys (private)Nothing here needs it; an app behind a login might

Words to put in a prompt or a review

Prerender (SSG)
Write the page’s HTML at build time. Nobody is signed in at build time.
Server render (SSR)
Make the HTML per request, for the person asking.
Client render (CSR)
Send a shell; a script fetches and draws the page.
Hydration
Attaching the page’s scripts to HTML the server already sent.
Island
One interactive part hydrated inside an otherwise static page.
Cache-Control
The header that tells every cache in between who may keep and share a response.
Streaming
Sending the page’s shell at once and the slow or personal parts as they finish.
And streaming, islands, and server components?Mixing modes inside one page

They mix the modes inside a page instead of choosing one per route. Next.js 16 with Cache Components prerenders a static shell and streams the parts that read cookies behind a <Suspense> fallback; its docs say that reading cookies() “doesn’t opt-in the whole route into dynamic rendering” (Caching, Next.js 16.3.6, fetched 23 September 2026). Astro islands hydrate only the interactive components. The question is the same at a smaller grain: for each part, does it differ per reader, and does it change between deploys?

03 / Follow one request

Watch each plan pay its bill.

The same site under five plans: everything static, everything in the browser, everything on the server, everything on the server cached at the CDN, and one mode per route. Each chapter sends the requests that plan is worst at. Step through, or open Try it and set each page’s mode yourself.

Rendering

Which mode does each page pay for?

Everything static

  • Install guidestatic
  • Changelogstatic
  • API keysstatic
ana · /account/keys CDN Origin · 0 renders

First HTML

—

What the reader sees

—

 

01/ 05
Everything static

ana opens her API keys.

“Sign in to see your keys”. The page was built at deploy, with no request and so with no user.

Reduced motion: choose a scene to see its completed state.

Read this scene

“Sign in to see your keys”. The page was built at deploy, with no request and so with no user.

Everything static. ana opens her API keys. First HTML: Sign in to see your keys. The reader sees: Sign in to see your keys. Answered by the origin. Out of date Server renders so far: 0.

Watch restarts the story when you come back. Step through keeps your step. Try it starts a fresh site each time you open it.

04 / Read the shape

The route chooses. The header says who may share.

Basic form is the choice itself, from two facts about a route. In the wild is the origin that makes it real: files at deploy, renders per request, a header on every response. At the call site the CDN and the browser do exactly what they are told, which is the whole problem when the header is wrong.

The choice, per route: a personal page renders per request and is never shared; a page that changes between deploys renders on the server and shares a short cache; the rest are files. Both sides make the same choice from the same facts.

TypeScriptReading
docs-site.ts
export type Mode = 'static' | 'server' | 'client';
export type Cache = 'private' | 'public-60' | null;
export interface RouteFacts {
	/** Different people should see different content. */
	personal: boolean;
	/** The content can change between deploys, without a new build. */
	changesBetweenDeploys: boolean;
}

/**
 * One route's rendering, chosen from what the route is. Personal pages render
 * per request and are never shared by a cache; pages that change between
 * deploys render on the server and share a short cache; the rest are files.
 */
export function chooseMode(facts: RouteFacts): { mode: Mode; cache: Cache } {
	if (facts.personal) return { mode: 'server', cache: 'private' };
	if (facts.changesBetweenDeploys) return { mode: 'server', cache: 'public-60' };
	return { mode: 'static', cache: null };
}
GoAlongside
main.go
type Mode string

const (
	Static Mode = "static"
	Server Mode = "server"
	Client Mode = "client"
)

type RouteFacts struct {
	Personal              bool // different people should see different content
	ChangesBetweenDeploys bool // the content can change without a new build
}

type Rendering struct {
	Mode  Mode    `json:"mode"`
	Cache *string `json:"cache"`
}

func cache(s string) *string { return &s }

// ChooseMode picks one route's rendering from what the route is.
func ChooseMode(f RouteFacts) Rendering {
	switch {
	case f.Personal:
		return Rendering{Server, cache("private")}
	case f.ChangesBetweenDeploys:
		return Rendering{Server, cache("public-60")}
	}
	return Rendering{Static, nil}
}
The behavior these examples promiseChecked by 10 shared scenarios for five plans in TypeScript and Go
  • A deploy writes each static route with no user and each client route as the shell “Loading…”, and purges the CDN.
  • A server route renders per request with the reader’s user. Its Cache-Control is public, s-maxage=60 if the plan says public, else private, no-store. Static files and shells are public, s-maxage=31536000, and a deploy purges them.
  • The CDN serves a fresh public copy by URL, whoever asks, and otherwise asks the origin.
  • A browser that gets the shell makes a second, private request for the content. A crawler does not run scripts.

Every expected result was produced by a separate model written from these rules, kept beside the examples in model/cases.py, not copied from either implementation.

Reading the TypeScriptsatisfies, and a header as the contract

plans uses satisfies so each plan keeps its literal name while being checked as a Plan. The CDN never looks at the plan: it reads cacheControl, as a real CDN does, so a wrong header is enough to leak.

Reading the GoA nil cache and a regexp

Rendering.Cache is a *string so a static route’s cache is nil, which encodes to the same JSON null the shared cases use. The CDN finds s-maxage with a compiled regular expression, the same rule as the TypeScript.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types docs-site.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module rendering-strategies

go 1.22
static: bo sees "Sign in to see your keys" · crawler sees "Install · npm install -g bramble" · renders 0
server-cached: bo sees "Keys for ana · bk_ana_7f2" · crawler sees "Install · brew install bramble" · renders 2
per-route: bo sees "Keys for bo · bk_bo_91c" · crawler sees "Install · npm install -g bramble" · renders 2

The per-route crawler still sees the old guide because the fix was published without a deploy. That is the price of a static page, paid on purpose.

05 / Review the agent’s diff

“Cut origin load by about 90%.”

The claim is true for two of the three pages. A one-line header in a server hook is the most tempting performance change there is. Read which responses it reaches.

The agent’s pull request

“Cut origin load by about 90%: every GET response is now cached at the CDN for 60 seconds. Tests pass.”

// src/hooks.server.ts
			export const handle: Handle = async ({ event, resolve }) => {
			  const response = await resolve(event);
			(added)  if (event.request.method === 'GET') {
			(added)    response.headers.set('cache-control', 'public, s-maxage=60');
			(added)  }
			  return response;
			};
			
You are reviewing this change. What do you do?

06 / How it fails

Each mode fails where its cost lands.

Here is each way the docs site can go wrong, what a reader sees, and what the per-route plan does.

Failure modes of one page request
What goes wrongWhat the reader seesWhat the per-route plan doesBacked by
Shared when it should be privateSomeone else’s keys.Keys render per request with private, no-store.Cases “ana, then bo, open their keys”, “bo opens his keys 61 seconds after ana”
Built with no reader“Sign in” while signed in.Never prerenders a personal page.Case “ana opens her keys”
StaleThe old install command until the next deploy; the old changelog for up to 60 s.Accepts both on purpose: the guide changes on deploy, the changelog is shared briefly.Cases “a writer publishes a fix…”, “a release ships…”
Empty first HTMLA crawler indexes “Loading…”.Puts every page’s content in its HTML.Case “a crawler opens the install guide”
SlowTwo round trips before the content.One, from the CDN or the origin.Case “a reader opens the install guide”
Origin downThe guide still loads from the CDN; the keys page fails.Nothing more; the static page is what survives.Authored

Caching and invalidation covers what a shared cache may keep, and Containing failure what keeps a site useful when its origin is slow or down.

07 / Is it worth it?

A choice per route costs one decision per route.

One mode for the whole site is one decision. One per route is three here, and a line of configuration each. Hold the whole-site answers and the per-route answer up against the changes a docs site always gets.

The same four changes, made to each plan
ChangeAll staticAll serverAll clientPer route
A second entry point: a “billing” page per accountCannot: it needs a user.Works; another render per visit.Works; another shell and request.Server, private, by the same two facts.
Move to another host or CDNFiles move anywhere.Needs a server runtime.Files plus an API.Files plus a small server. No real difference.
A rule changes: docs publish without a deployNeeds rebuild on publish.Already fresh.Already fresh.Move the guide to server with a short public cache.
A second team owns the account pagesTheir pages cannot be static.Their caching choices can leak yours.No leak; no search either.Their routes carry their own mode and header.

Before changing how a site renders, decide what you will look at:

  • Time to first content at the 75th percentile, per page, from real visits. The Core Web Vitals report this as Largest Contentful Paint.
  • Origin requests per page view, per route. The accepted result depends on the route: zero for the guide, one per minute for the changelog, one per view for keys.
  • Responses that read a cookie and were served from a shared cache. The accepted number is zero, and a log line per response can count it.

This page did not measure a real site, so it has no numbers to give you. The model counts renders and round trips; it does not time them.

08 / Ask for it

Two prompts, two docs sites, one CDN.

We sent two agents the same request for this docs site at the same time, both running Claude Sonnet. The shared prompt described the pages, the content, sign-in, and a CDN that caches by URL when a response says public and does not look at cookies. The architecture prompt added a mode per page: a static guide re-rendered on publish, a server-rendered changelog shared for 60 seconds, a private keys page, and content in every page’s HTML. Then a script put its own CDN in front of each build and sent the same requests.

What the checker found, run 2026-09-23
What the checker didPlain promptArchitecture prompt
The first HTML of the guide and the changelogboth pages carry their contentboth pages carry their content
ana, then bo, open their keys through the CDNbo saw his own keys (Cache-Control: "private, no-store, no-cache, must-revalidate")bo saw his own keys (Cache-Control: "private, no-store")
A signed-out reader opens the keys page after anaasked to sign inasked to sign in
A writer publishes a fix to the guideshown after 301 s, when the CDN copy expiredshown after 3601 s, when the CDN copy expired
Ten readers open the changelog, then a release ships1 of 10 reached the origin; the release showed after 301 s1 of 10 reached the origin; the release showed after 61 s

The failure the story is built on did not happen. Both agents read “the CDN does not look at cookies” and marked the keys page private; bo never saw ana’s key through either build. Both put every page’s content in its HTML. An agent told how the cache behaves reasons about the cache.

The builds split on freshness, and the architecture prompt was the one with the hole in it. It asked for a guide “rendered to static HTML at startup and again when a new version is published”, served with a public header. The agent did exactly that and chose max-age=3600. Re-rendering at the origin does nothing to the copy the CDN already holds, so a published fix reached readers an hour later. The plain build cached every public page for 300 seconds, so both the fix and a new release took five minutes; the architecture build’s changelog, at s-maxage=60, took one.

server.js · plain prompt
// --- public content pages: cacheable by the CDN, cookie-independent ---
if (method === 'GET' && pathname === '/docs/install') {
  sendHtml(res, 200, renderInstallPage(), 'public, max-age=300');
  return;
}

if (method === 'GET' && pathname === '/changelog') {
  sendHtml(res, 200, renderChangelogPage(), 'public, max-age=300');
  return;
}
server.js · architecture prompt
// GET /docs/install - serve the pre-rendered static HTML.
if (req.method === 'GET' && pathname === '/docs/install') {
  res.writeHead(200, {
    'Content-Type': 'text/html; charset=utf-8',
    'Cache-Control': 'public, max-age=3600',
  });
  res.end(installGuideHtml);
  return;

The missing line is about the cache, not the page: when a static page is re-rendered, purge its CDN copy, or give it a cache time you can accept as the delay for a published change. A static page is only as fresh as the longest cache in front of it.

How the runs were made and checkedOne run each, two checker runs
  • Both agents received the prompts word for word, in fresh contexts, at the same time. The only differences were the Architecture block and the output folder. The shared part told them to stop any process by its PID; neither used a pattern kill.
  • Both wrote scratch files (saved pages, cookie jars, a PID file) to the system temp folder while testing, outside their own folders. None is part of either build.
  • The checker puts its own CDN in front of each build: it shares a GET by URL when Cache-Control says public, ignores cookies, and has a clock that moves only when a question moves it. Its first run said “within an hour” for both fixes, which hid the difference between 300 and 3,600 seconds; the second run reports the first moment a change appears. Both runs are kept.
  • One run of each prompt is a sample, not a measurement of a model.

09 / Hold it there

Make a public personal page impossible to ship.

The dangerous change is small: one header, one prerender flag. Three kinds of check catch it.

  1. The framework’s own door

    SvelteKit’s page options make the mode a line in the route: prerender, ssr, and csr. It refuses to prerender some pages outright: “Pages with actions cannot be prerendered”, and “Accessing url.searchParams during prerendering is forbidden” (Page options, fetched 23 September 2026). Next.js 16 with Cache Components keeps runtime data out of the shared static shell unless you cache it: a component that reads cookies() streams at request time behind <Suspense> (Caching). Neither framework stops you setting a public header on a personal response. That one is yours.

  2. A rule a check enforces

    A route that reads the session may not be prerendered or marked public. This check is twenty lines; run against a three-route fixture, it flagged the keys route that set a public header, and nothing else. The same rule written for dependency-cruiser cannot see an export or a header, which is why this one reads the source. Architecture as rules covers turning a sentence like this into a check.

    check-personal-routes.mjs
    // check-personal-routes.mjs
    // A route that reads the session may not be prerendered or marked public.
    for (const file of routeFiles('src/routes')) {
    	const source = readFileSync(file, 'utf8');
    	if (!/\bcookies\b/.test(source)) continue;
    	if (/export const prerender = true/.test(source))
    		problems.push(`${file}: reads cookies and is prerendered`);
    	if (/cache-control['"]?\s*:\s*['"][^'"]*\bpublic\b/i.test(source))
    		problems.push(`${file}: reads cookies and sets a public cache header`);
    }
    node check-personal-routes.mjs
      error src/routes/account/keys/+page.server.ts: reads cookies and sets a public cache header
    1 problem in routes that read the session.
  3. A check on what actually happens

    Source checks miss a header set in a hook. So send the requests: sign in as two people, fetch the keys page through a cache that shares by URL, and fail if the second sees the first’s key. The lesson’s spec does it against the model; the checker in section 08 does it to the recorded builds.

    check-runs.mjs
    async 'ana, then bo, open their keys'(cdn) {
    	const ana = await cdn.get('/account/keys', await login('ana'));
    	const bo = await cdn.get('/account/keys', await login('bo'));
    	const leak = text(bo.body).includes('bk_ana_7f2');
    	return { ana: { cacheControl: ana.cacheControl, from: ana.from }, bo: { from: bo.from, sawOwn: text(bo.body).includes('bk_bo_91c') }, verdict: leak ? `bo was shown ana’s key (Cache-Control: "${ana.cacheControl}")` : text(bo.body).includes('bk_bo_91c') ? `bo saw his own keys (Cache-Control: "${ana.cacheControl}")` : 'bo saw neither key' };
    },
Where this lives in SvelteKit and Next.jsEvery +page.ts and page.tsx already makes this choice. A personal page is where it gets tested.

Where it already is in your components

Every SvelteKit route has a mode, whether you wrote export const prerender or took the default; this site’s own lesson routes set prerender in their loaders. In Next.js 16, every component either lands in the prerendered shell, is cached with 'use cache', or streams at request time. When you move a data read into or out of a <Suspense> boundary, you are choosing a rendering mode for that part.

When you have to own it

The first page that knows who is reading. Its data comes from a cookie, so it cannot be in a file built for everyone, and nothing in between may share it. In SvelteKit that is a +page.server.ts that reads cookies and calls setHeaders with private, no-store. In Next.js it is a component that reads cookies() behind <Suspense>, with no 'use cache' on it.

The install guide, the same for every reader. Next.js 16 caches the page with use cache and cacheLife, so it lands in the static shell; SvelteKit prerenders it with export const prerender = true.

ReactAlready in your code
app/docs/install/page.tsx
// app/docs/install/page.tsx · Next.js 16 with cacheComponents: true
import { cacheLife } from 'next/cache';

type Doc = { title: string; body: string };

async function getDoc(slug: string): Promise<Doc> {
	const response = await fetch(`https://cms.example.com/docs/${slug}`);
	if (!response.ok) throw new Error(`docs service answered ${response.status}`);
	return response.json() as Promise<Doc>;
}

// Every reader sees the same guide, and it changes when the docs are
// published. Caching the page puts it in the prerendered static shell: the
// HTML carries the guide, and no request renders it again.
export default async function InstallPage() {
	'use cache';
	cacheLife('days');
	const doc = await getDoc('install');
	return (
		<article>
			<h1>{doc.title}</h1>
			<p>{doc.body}</p>
		</article>
	);
}

10 / Make the call

Static where you can, server where you must, client where it earns it.

For bramble’s docs I would prerender the guide, server-render the changelog with a one-minute public cache, and server-render the keys page privately. I accept that a docs fix waits for a deploy and that the changelog can be a minute old. I would reconsider when writers need fixes live without a deploy (the guide moves to server with a short cache), or when a page becomes an app that only signed-in people use and search engines never see (client rendering starts to earn its second request).

A whole-site answer is fine when every page really is the same kind: a marketing site with no accounts can be all static; an admin tool behind a login can be all client.

Keep a note of the decision

Why
Three pages with different readers and different change rates: one for everyone that changes on deploy, one for everyone that changes between deploys, one per reader.
What
A mode per route, chosen from two facts, with Cache-Control saying who may share each response. It lives next to each route.
Constraint
A CDN shares by URL and ignores cookies; a build has no reader; crawlers do not run scripts.
Fallback
A docs fix waits for a deploy; the changelog can be 60 seconds old; the keys page costs a render per visit and fails if the origin is down.
Reconsider when
Docs must go live without a deploy, a page becomes signed-in only and unsearchable, or origin cost for a personal page starts to matter.

Take it with you

Explain it without saying “server-side rendering”: “Each page is built in one of three places: once when we ship, on our server for each visit, or in the reader’s browser. We pick per page from two facts, whether it differs per reader and whether it changes between releases, and the cache header says who may keep a copy.” Then list your own app’s routes and write those two facts next to each.

Paste into your next prompt, and fill in the blanks

Choose a rendering mode for each page from what the page is, and
write the choice down next to the route.
<Pages everyone sees the same, that change on deploy>: prerender to
static HTML. When one is re-rendered, purge its CDN copy.
<Pages everyone sees the same, that change between deploys>: render
on the server, Cache-Control: public, s-maxage=<seconds>.
<Pages that read the session>: render per request, Cache-Control:
private, no-store. Nothing that reads a cookie is ever public.
Every page's HTML carries its content without JavaScript;
<interactive parts> hydrate as islands.
Connections to follow nextRelated lessons

Take the docs site into your editor. Add a search page that reads ?q=, and decide its mode and its header before you write it.

Back to architecture →