A documentation site serves the same pages to everybody. The reference page for a validator function does not change between two readers, it changes when someone merges a pull request. And yet the default way to ship one, on nearly every framework, is to render it again for every visit.
We took that apart on two real sites: valibot.dev and formisch.dev, the docs for Open Circle's TypeScript libraries. Together they are more than 1,100 pages, and both were rendering at request time. This is what actually had to change, including the part that looks trivial and is not.
The cost was never in the browser
It is worth being precise about where the time went, because the obvious suspect was innocent.
Both sites are built with Qwik, which resumes rather than hydrates. There is no framework boot on the client, no re-execution of component trees to attach handlers. The browser side was already about as lean as it gets.
The cost sat one layer up. Every page view went through a function on the edge, and that function existed for one reason: reader preferences. Theme, the chapters column, and the selected framework all lived in httpOnly cookies, read server side through routeLoader$. Once the HTML depends on a cookie, the HTML belongs to one request. You cannot hand it to a CDN and let it answer the next thousand readers with the same bytes.
So the cookie was not a detail of the implementation. It was the thing keeping a static site dynamic.
Pre-render everything, keep the navigation
Swapping the Vercel Edge adapter for the Qwik Router SSG adapter changes what a build produces. Instead of a server bundle, every route becomes two artifacts: an HTML file, and a q-data.json next to it.
The split matters. The HTML is what a cold visitor gets, straight from the cache. The q-data.json is what client-side navigation fetches when the reader clicks through to the next page, so in-site navigation still feels like an application rather than a series of full page loads. Nothing about the reading experience changes. The only difference is that no request wakes anything.
The hard part: preferences without a flash
This is where a straightforward migration turns into a real problem.
Move a cookie-backed setting into the browser and there is an implementation that suggests itself immediately, and it is wrong. You read localStorage inside useVisibleTask$, or the equivalent post-hydration hook in whatever framework you are using, and apply the value. That code runs after the first paint. So the reader sees the default first, and their own setting a frame later.
For the theme that is a flash of a light background at somebody who deliberately chose dark. It is a small thing that feels like a broken site.
For the chapters toggle it is worse, because that setting is not a colour. It changes max-width, it adds or removes a side column, and it changes the page padding. Applying it after paint is a layout shift on the content the reader is already looking at.
The fix is not to make the post-paint work faster. It is to decide before paint at all:
A small blocking script in the
<head>readslocalStorageand toggles a class on the<html>element. Blocking is the point. It has to run before the browser draws anything, and that is the one place in the document where you can guarantee it.The stylesheet keys off that class and only off that class. Never off a signal that becomes available after resumption, because that reintroduces the same ordering problem through a different door.
In Tailwind v4, the visible state is the unprefixed base and a custom
no-chaptersvariant overrides it. Same model asdark. Writing it the other way round, treating hidden as the base, means the common case is the one that has to wait for a class.The chapters aside is always rendered and hidden through the variant. An element that is in the tree and hidden costs nothing to hide. An element that is conditionally rendered reflows the page when it appears.
Signals still exist in the component. They just do not decide the layout any more. Their only job is to label the toggle button, which is post-paint work that nobody can see.
The general rule underneath this: anything that changes geometry has to be resolved before the first paint, or it will be visible as a shift. Anything that only changes a label can wait.
OG images belong in the build
The old setup had a dynamic /og-image route that drew a social preview card per request with @vercel/og. That is a function on the request path serving crawlers, which is a slightly absurd thing to keep once you have removed the function serving humans.
Now one PNG per route is generated during the build and piped through sharp with palette quantisation. The cards use a handful of flat colours, so quantising them is close to lossless and takes a large bite out of the file size. The <head> points straight at /og/<slug>.png. A crawler asking for a preview gets a file off the CDN.
Cache headers, matched to what the file actually is
Static output is not one category, and giving it one cache policy is how you either leave performance on the table or serve a broken site after a deploy.
Hashed output under /build, /assets and /fonts gets a year and immutable. The filename changes when the contents change, so there is no such thing as a stale copy.
q-data.json deliberately does not get that treatment, and this is the one that catches people. Its URL is stable, but its content changes on every deploy, because it references build symbol hashes. Mark it immutable and a returning reader navigates client side, gets a cached payload from the previous deploy, and asks for symbols that no longer exist.
So it gets a short browser max-age, a long shared max-age so the CDN still absorbs the load, and a week of stale-while-revalidate. Navigation stays instant, and no browser holds a payload pointing at a build that is gone.
One patch, or you lose pages silently
Qwik Router's SSG appended a trailing slash to anything it decided was a route, including paths that end in a file extension. The effect was not an error. It was pages missing from the build output, and dotted paths such as .md and .png breaking.
That failure mode is worth naming on its own, because it is the dangerous kind. A build that crashes gets fixed the same day. A build that quietly produces fewer pages than it did yesterday gets noticed when someone reports a dead link.
The patch narrows the check to known extensions, and the missing routes came back in the build output. Diffing the route manifest between the old and new builds, rather than trusting the build to have succeeded, is what surfaced it.
What you are left with
The build leaves HTML, JSON, fonts and images. There is no runtime tied to a particular adapter, so the same output can be served by any CDN, and the hosting decision stays open rather than being made once and inherited forever. Both sites currently run on Cloudflare, and a cache hit returns the first byte in roughly 80 ms from Europe.
The part worth taking away is not the adapter swap, which is a configuration change. It is that the cookie was the architecture. Every piece of per-request rendering was there to support three reader preferences, and moving those three preferences into a class on <html> is what made the whole site cacheable.
The worked example, including what it meant for the team maintaining the libraries: