The cost sat on the request path
Valibot and Formisch are TypeScript libraries with large reference sections: 819 pages on valibot.dev and 291 on formisch.dev. Both docs sites rendered on Vercel Edge at request time. Qwik resumes instead of hydrating, so the browser side was already lean, but every page view still went through a function. On top of that, reader preferences (theme, chapters, framework) lived in httpOnly cookies read by routeLoader$, so the HTML was tied to a specific request and could not simply be handed to a CDN.
Pre-render everything, keep the SPA
Swapping the Vercel Edge adapter for the Qwik Router SSG adapter turns every route into plain HTML plus a per-route q-data.json. The HTML is what the browser gets on first load, and q-data.json is what client-side navigation fetches afterwards. Nothing changes for the reader, except that no request wakes a function any more.
The hard part: preferences without a flash
Moving a cookie-backed setting into the browser has an obvious implementation that is wrong. Reading localStorage inside useVisibleTask$ runs after first paint, so the page renders the default and then snaps to the stored value. For the theme that is a flash of colour. For the chapters toggle it is a real layout shift, because the setting changes max-width, the side column, and the page padding.
The fix is to decide before paint. A blocking inline script in the head reads localStorage and toggles a class on the html element, and the layout keys off that class, never off a post-hydration signal. In Tailwind v4 the visible state is the unprefixed base and a custom no-chapters variant overrides it, the same model as dark. The chapters aside is always rendered and hidden through the variant, so hiding it reflows nothing. Signals still exist, but only to label the toggle button.
OG images moved into the build
The old dynamic /og-image route drew a card per request with @vercel/og. Now one PNG per route is generated at build time and piped through sharp with palette quantisation, since the cards use a handful of flat colours. The head points straight at /og/.png, so a crawler asking for a preview gets a file off the CDN instead of waking a function.
Cache headers matched to the content
Hashed output under /build, /assets and /fonts gets a year and immutable. q-data.json deliberately does not: the URL is stable but the content changes on every deploy, because it references build symbol hashes. It gets a short browser max-age, a long shared max-age, and a week of stale-while-revalidate. Navigation stays instant, and no browser holds a payload pointing at symbols the deploy removed.
One patch to stop losing pages
Qwik SSG appended a trailing slash to anything that looked like a route, including paths ending in a file extension. Some pages never made it into the build at all, and dotted paths such as .md and .png broke. A small patch narrows the check to known extensions, and the missing routes came back in the build output.
Output that any CDN can serve
The build leaves HTML, JSON, fonts and images, with no runtime tied to a particular adapter, so the same output can be served by any CDN and the hosting decision stays open. Both sites currently run on Cloudflare and return the first byte in roughly 80 ms from Europe on a cache hit.
Part of our OSS Partner Program
This was the first delivery under our OSS Partner Program. The point is to take recurring infrastructure and maintenance work off open-source teams, so their time and funding go back into the libraries the rest of the ecosystem depends on.
