Server components and an honest client-JavaScript budget
Server components make the default free, which is exactly why the interesting question becomes what earns a client boundary — and how much interactive work you can do after crossing one without ever calling setState.
The pitch for server components is that most of a page does not need to be JavaScript. That is true and it is also the least interesting part, because it turns the design question inside out. When rendering on the server is the default, nobody has to argue for it. What needs an argument is each individual decision to leave the server.
This site has eleven client components. Not eleven files with hooks in them — eleven 'use client' boundaries in the entire application, covering a homepage with a hero canvas, a pinned card deck, two marquees, a live status bar, a mobile menu, a typed masthead mark and a contact form.
The number is small because each one had to answer the same question, and because crossing the boundary turned out to be a much weaker commitment than expected.
The test for a boundary
A component earns 'use client' when it needs something the server does not have: the DOM, a frame loop, pixel data, a pointer, localStorage, a media query, or an event a user produces. Not "it is interactive" in the loose sense — the contact form's markup, labels, validation attributes and submit target are all server-rendered, and the boundary exists for the submission state alone.
Two rejections are more instructive than the acceptances.
The footer used to be a client component because it carried the theme selector. When the theme control moved into the status bar — which was already a client component and already the site's one place for theme and accent — the footer became a server component with no interactive state at all. The boundary had been paying for a job something else was already doing.
The section navigation is not a client component despite highlighting the current section. The scroll observer is one small client component that writes an attribute; the navigation itself is server-rendered markup that reads it in CSS.
The general shape: push the boundary down to the smallest thing that genuinely needs the browser, then let CSS and the DOM carry the result outward.
Crossing the boundary does not mean shipping React state
This is the part that changed how the rest of the site was built.
A client component still renders on the server. Its initial HTML is complete, and hydration is what attaches behaviour to already-correct markup. So the interesting budget is not "how many client components" but "how much React work happens after mount". On this site the answer is frequently none.
The hero's raster field is the extreme case. It is a canvas rendering roughly 36,000 cells a frame with pointer interaction, ordered dithering and a depth model. After mount it does not call a single setter. Everything is typed-array writes behind a ref, and the component function never runs again.
The typed masthead mark is the same idea at a smaller scale. It types character by character by writing textContent through a ref on a schedule — zero re-renders for the whole sequence — and the server renders the finished string, so the mark is complete without JavaScript, on a crawler, and under reduced motion.
For values that genuinely come from outside React, useSyncExternalStore does the job without turning state into a render loop:
const themeId = useSyncExternalStore(subscribeTheme, readTheme, readServerTheme);with readServerTheme returning the default, which is what makes the server render deterministic rather than a guess about the visitor.
The bit where the framework fights you
One decision resisted every clean spelling, and it is worth recording because the failure was silent.
A returning visitor's stored theme has to be applied before first paint. React cannot do it: by the time an effect runs, the default palette is already on screen and the visitor sees a flash of the wrong colours. It has to be a synchronous inline script that runs during parse.
Two plausible placements do not work:
- A raw
<script>inside an explicit<head>gets serialised into the RSC - payload instead of emitted as an executable tag.
next/scriptwithbeforeInteractivedoes the same thing.
Both were verified by grepping the built HTML — and before that, by watching the stored value persist correctly while the applied attribute still came back as the default after a reload. Nothing threw. The script was simply not there.
As the first child of <body> it is emitted inline and runs during parse, after the stylesheet and before content is painted.
There is a related trap in the same area. The storage key had to come from a shared module rather than from the selector component, because a value exported from a 'use client' module is a client reference on the server — importing it into the layout compiled to localStorage.getItem(undefined), with no error anywhere.
Both of these are the same lesson: on the server/client seam, the failure mode is not an exception. It is code that quietly does nothing, so the verification has to be "look at the bytes that shipped" rather than "it did not throw".
What the budget buys
The honest accounting is that this architecture does not make the page free. The raster field is the single largest client cost on the site and it is there because the hero is the composition. What the budget does is make that cost visible and local: it is one component, one boundary, and turning it off is deleting one element rather than unpicking a framework.
Every other section — work deck, articles, about, contact, footer, case studies, the whole article route — renders on the server and ships its interactivity as either nothing or one small named component.
That also means the failure modes stay boring. No JavaScript still gives a complete page. A crawler sees finished text. Reduced motion lands on the resting composition, which is the same discipline the motion contract enforces. The enhancement is always layered on top of something that already worked.
If there is one rule worth taking from this, it is that 'use client' should be read as a question rather than as a directive — and that the answer is usually "a smaller component than the one you were about to annotate".