This article describes how to build a modern blog reading experience under one strict constraint: every public page must work completely with JavaScript disabled, with client-side script permitted only to decorate what the server already rendered. The result on this site is a reading interface with a scroll-synced table of contents, a reading progress indicator, code copy buttons, heading anchor links, footnote hover previews, and an image lightbox, whose entire client-side cost is one small bundle of gzipped JavaScript, loaded by a single script tag: 1.67 kB at the current measurement (2026-08-26; it was 1.59 kB as a lazily loaded chunk plus a 0.22 kB loader when first published). The article covers the method for holding that constraint, the pipeline rules that emerge when two different programs write the same content, and several decisions about what not to build, with reasons.
Progressive enhancement is an old idea, and I am not claiming novelty for it. What I can add is a worked example with current tooling, exact costs, and the specific places where the discipline required a decision rather than a habit.
The progressive enhancement rule: server-complete, with every fallback named#
The rule I recommend, and the one this site enforces, has two parts. First, every feature on a public reading route must have a server-rendered form that is complete without script: navigation navigates, filters filter, code arrives highlighted. Second, every client-side enhancement must have a named fallback recorded somewhere a reviewer can find, even when the fallback is "nothing." A reading progress bar's fallback is nothing, because it is decoration; writing that down is what distinguishes a decision from an omission.
The second part sounds bureaucratic and is the entire method. When each enhancement must declare its fallback before it ships, the declaration forces the design conversation at the right moment: if you cannot name what happens without script, the feature is not an enhancement, it is a dependency, and it either moves to the server or does not ship. In practice the inventory for this site is short. The table of contents is server-rendered anchor links; script adds scroll-spy highlighting. Code blocks arrive fully highlighted from the build pipeline, including highlighted line ranges baked into the stored HTML; script adds a copy button and a language label. Footnotes are ordinary bidirectional links; script adds hover preview cards. Images link to their originals; script intercepts the click into a lightbox. Tag and year filters are server-side query parameters. Nothing on the list requires the network after page load, and nothing renders content that was not already in the HTML.
How a complete blog UI fits in 1.59 kB of gzipped JavaScript#
The figure (1.59 kB gzipped when this was first published; 1.67 kB re-measured 2026-08-26) is not the product of heroic minification. It follows from the architecture: when the server renders everything and script only attaches behavior to existing elements, the script contains event listeners and class toggles, which are cheap. There is no framework runtime in the bundle, no templates, no state management, because there is no client-rendered state. Since 2026-08-26 that sentence is true of the whole page, not just the bundle: this site's public routes no longer hydrate a client framework at all, so the reading enhancements are prebuilt into a self-contained module and loaded by an ordinary script tag, served only on blog routes. The rest of the site pays nothing.
The measurement itself deserves a paragraph, because my first measurement was wrong in an instructive direction. During verification, a 250-line TypeScript file emitted a 0.05 kB chunk. It was not plausible, and implausibility was the correct alarm: a misconfigured ?url import had caused the bundler to copy the file verbatim as a static asset, meaning the browser would have been served raw TypeScript. The general procedure I would recommend for any bundle claim: distrust any number that surprises you in either direction, open the emitted files, and confirm the served bytes are the compiled form. A second false signal came from the automated browser harness, where a form-input tool intermittently failed to reach the framework's synthetic event system, making a working autosave feature look broken; real keyboard input resolved it. Verification tooling fails in both directions, and the habit that catches it is treating every surprising result as a claim about the harness first and the code second.
Rules when a build script and a live server both write the same content#
This blog's content is written by two different programs: a build script running in Node, and the editor's save path running in the Worker. When this article was first published, both produced the same generated artifact, and the pipeline's byte-comparison gate required the two writers to agree exactly. Three rules fell out of making that true, and each was learned by watching the gate fail. The artifact came out of the repository on 26 August 2026; the update at the end says which of the three rules survived it, which is all three.
First, derived values that depend on commit history cannot live in the gated artifact. I wanted a "last updated" date derived from git. The artifact is generated before the commit that will contain it, so a git-derived date records the previous commit, and the next build computes a different value, which means the gate fails after every ordinary content commit, by construction rather than by accident. Dates of that kind belong in a layer outside the byte-compared artifact; here they are written at database sync time.
Second, corpus-level derived data must be recomputed over the whole corpus by every writer. Related-posts lists are a property of the set: adding one post changes the correct lists of others. An editor save that updated only its own post's related list would leave the artifact inconsistent with what a fresh full generation produces, and the gate would correctly flag it. Both writers therefore recompute relatedness globally on every write. At this corpus size that costs nothing; at a much larger size you would need either an incremental algorithm both writers share or a decision to move relatedness out of the gated artifact entirely.
Third, and this is the operational hazard I most want to pass along: if the deployed Worker's pipeline version differs from the main branch, the live editor becomes a machine for writing artifacts that main cannot reproduce. This happened during development, when a Worker deployed from a feature branch accepted an editor save and committed an artifact in the new pipeline's shape against a main branch still running the old pipeline. The gate on main went red through no fault of the content. The rule that prevents it: a deploy that changes the pipeline and a mainline that has not merged it cannot coexist with an active editor. Sequence deploys with merges, or lock the editor during the window.
Open Graph card generation at build time: keep external facts out of the gate#
Open Graph card images here are generated at build time with satori and resvg at 1200 by 630, stored in R2 under a key derived from the slug plus a hash of slug, title, and description, and served immutable. A per-post cover image takes precedence when present. Two decisions in this subsystem generalize.
Generation runs at build time in Node, not in the Worker, because the measured costs said so: the Worker-compatible rendering path would have added 1.87 MB to an already large Worker for code that runs only on saves, and the resvg WebAssembly build requires the runtime compilation Workers refuse. The accepted consequence is that a post created or retitled in the editor has no card until the next build runs. The editor stores no image reference in that window rather than a URL that would 404, on the principle that a missing image is a degraded state and a broken image reference is a bug.
The second decision is subtler. The card's R2 key is deterministic and could have been written into the gated artifact, and it deliberately was not. Whether an object exists in R2 is a fact about R2, not a fact about the markdown, and the gate compares statements about the markdown. Mixing an external system's state into a byte-compared artifact means the gate fails for reasons no content change explains. The card's location lives in a database column instead. As a general rule: a reproducibility gate should compare only what its inputs fully determine.
Version history from git, and an autosave that never commits#
Version history in the admin is a window onto git rather than a mechanism of its own: it lists a post's commits through the GitHub API, renders diffs, and implements restore as a new commit through the same atomic save path used everywhere else, never a history rewrite. I verified the non-destructive property live: after a restore, both earlier commits remained intact and the gate was green. One detail worth copying into any implementation: restored content re-runs the validation gates, which closes a hole where restoring an old commit would republish text that predates a rule and was never checked against it.
Autosave is the deliberate opposite. Draft state persists locally in the browser and survives a reload, verified with a forced reload mid-edit, and it never commits. A system whose integrity story is "every change is a reviewed commit" cannot also run a background process that quietly commits keystrokes; the commit button remains the only writer.
What was refused, and why the refusals are documented#
Part of the method is recording what you chose not to build, with one sentence of reasoning each, in the project documentation rather than in anyone's memory. From this phase: no comments, pending an actual decision about moderation and storage rather than a default; no view counters, which need their own privacy and measurement decisions; no rich text editor, because a textarea plus a live preview rendered by the one true pipeline was sufficient to ship and WYSIWYG is a separate commitment; and no 103 Early Hints, because the feature requires a zone and this hostname is not one until a pending DNS cutover, a fact established by checking rather than assumed. The purpose of writing refusals down is insurance: the cheapest way to prevent a future contributor, human or AI agent, from helpfully building the wrong thing is a sentence explaining why it is absent.
Limitations#
The no-JavaScript claim is scoped to public reading routes; the admin interface requires script and makes no such promise, which I consider the correct boundary since its audience is one authenticated person. The bundle figures are from the production build at the time of writing and will drift as features land; what should not drift is the rule that produced them. And the two-writer rules above were learned against a byte-comparison gate; a project without one avoids those specific failure modes and loses the corresponding guarantee, which is a trade you should make knowingly rather than by omission.
This is the fourth post in the series, following the content pipeline; the next one covers the search engine underneath this reading experience: SQLite FTS5 on D1, two indexes, rank fusion, and a median query time of six milliseconds.
Update, August 2026#
Two findings from an external review month belong in this article's inventory. The first is the class-closure story: one public route had no cache headers at all, not because anyone decided that, but because the headers were copied route by route and the newest route was forgotten, the copy-paste failure this article's own two-writer section warns about wearing a different hat. The fix that matters was not adding the missing headers; it was adding an assertion that every public route exports them, so the class of miss is closed rather than the instance. The second finding is the browser permissions header, and it vindicates the fallback inventory in a direction I did not anticipate: when deriving the site's Permissions-Policy from what the code actually uses, the one capability that must never be denied turned out to be clipboard access, because the copy buttons this article describes swallow a refusing clipboard silently, by design, since a copy button that throws is worse than no copy button. An enhancement that fails politely is invisible to every test that only checks the happy path, which makes the written fallback inventory the only place the dependency was recorded. Derive your permissions from your inventory, not from a copied denylist, or the denylist will eventually deny something your own page quietly needs.
A third finding arrived after the review month closed, and it retires the machinery behind this article's middle section. The committed artifact that the two-writer rules were written against came out of the repository on 26 August 2026, for the reasons recorded in the pipeline article's update: git holds markdown only, the database holds the only rendered copy, and each row carries the hash of the source it was rendered from. The three rules survived the change better than the design did. Derived values that depend on commit history still cannot live in what two writers must agree on, so the last-updated date is still written at sync time. Corpus-level derived data is still recomputed over the whole corpus by every writer; it is now read from the database rather than from a file. And the deploy-versus-mainline hazard is now caught by a drift table the deploy prints, post by post, rather than by a red gate on main. The Open Graph section's rule survives untouched, since it was about keeping external facts out of whatever two writers must agree on, and that requirement did not go anywhere.
Built on the stack described at /colophon.