Server Components moved the boundary
K81R's App Router adoption revisited the universal JavaScript problem by making browser responsibility explicit at the component boundary.
Seven years after writing about the universal JavaScript tax, I moved a substantial React tree to Server Components and deleted code from the browser without deleting the interface.
K81R's source view rendered document identity, authored structure, revision status, evidence links, relationships, and a small interactive selection tool. The old route assembled data on the server, serialized a broad page model, sent the component implementation to the browser, and hydrated most of the tree even though the primary activity was reading.
The App Router's stable core offered a more precise question than “Is this page server-rendered?”
The question became: which parts of this interface actually require browser responsibility?
That changed component architecture, data flow, security review, caching, testing, and how I defined frontend work.
Server rendering had not removed the browser's copy
The previous route produced useful HTML on the server. It still sent React code and serialized data so the browser could reconstruct and hydrate the component tree.
For a document page, that meant the browser received logic for:
- Formatting source metadata.
- Rendering static heading hierarchy.
- Mapping revision status.
- Building relationship labels.
- Transforming source nodes into display blocks.
- Creating links whose destination was already known.
Only evidence selection, local filtering, copy feedback, and a few navigation enhancements required client state or event handlers.
Server rendering had improved initial delivery while preserving a client-owned mental model. Server Components made implementation absence from the browser a default possibility.
A Server Component was not an API endpoint
The first design moved database queries directly into leaf components. It felt like elegant colocation and scattered domain access through the tree.
I kept K81R's supported query boundary:
const source = await getAuthorizedSourceView({
sourceId,
revision,
decisionContext,
})The route composition loaded disclosure-safe source identity and passed bounded views into rendering components. A component could call a narrow server query where streaming or independent ownership justified it, but it could not import raw repositories casually.
Server Components were an execution and composition model. They did not replace domain APIs, authorization, or table ownership.
This distinction preserved the ability to test source authority independently from framework rendering.
The client boundary followed capability
I marked the evidence selector as a client component because it needed browser state, event handlers, focus management, and an enhanced save interaction. Surrounding source prose, citation links, and revision labels remained on the server.
The boundary crossed a small descriptor:
type SelectableEvidence = {
sourceId: string
sourceRevision: number
passageId: string
label: string
selected: boolean
}The client did not receive full source nodes, policy data, index diagnostics, or unrelated document fields. It received the minimum state needed to present and submit the interaction.
The meaningful measure was not the number of files containing a client directive. It was the code, data, dependencies, and lifecycle the browser had to own.
One large island was not automatically better
My first attempt minimized client boundaries by wrapping the entire document body and inspector in one client shell. That reduced boundary count and restored the broad hydration tree I was trying to question.
The shell received a serialized document and rendered server-suitable content in the browser. Any dependency imported below it joined the client graph.
I split by coherent interaction:
- Evidence selection and its tray shared client state.
- Copy feedback stayed local to copy controls.
- Static source blocks remained server-rendered.
- Related-document navigation used ordinary links.
- A client-side text filter activated only for long source lists.
Several small, purposeful client roots transferred less code and clearer state than one universal island. Count alone would have chosen the wrong architecture.
The directive marked a graph boundary
One client-marked module pulled its imports and descendants into the browser graph. A harmless barrel export included a syntax parser, server-only source types, and a database-shaped utility.
Architecture tests separated server-only, shared-pure, and client-capable packages. Client modules could import shared UI and validated contract types. They could not reach repositories, parser implementations, model adapters, secrets, or Node-only dependencies.
I inspected build output to catch dependencies that static rules missed. A client chunk containing an unexpected parsing library produced a regression even if the page still worked.
The boundary operated through module graphs, not component labels on a diagram.
The old page model contained complete documents, relationship records, parsed node objects, and functions added during composition. It was convenient because the browser received one shape.
Crossing a Server-to-Client boundary required transferable values and made the excess visible. I replaced the page model with narrow descriptors tied to each interaction.
The reduction improved several properties:
- Smaller response payload.
- Fewer sensitive fields available to browser code.
- Stable client contracts independent of database shape.
- Clearer cache keys and state identity.
- Easier tests for exactly what the interaction needed.
Serialization constraints were useful design pressure. They were not authorization. The server query still enforced what the requester could see before constructing the descriptor.
Server-only code and data did not ship to the browser bundle. Secrets and source repository access could remain outside the client graph. This reduced accidental exposure.
It did not make server-rendered content private. Anything rendered into HTML or the component payload was available to the requester and network. A broad object passed to a client component could leak fields even if the component never displayed them.
I inspected initial HTML, serialized client props, streaming responses, metadata, and error payloads under restricted fixtures. Disclosure safety applied to every representation.
Server location protected implementation and capabilities. Authorization protected data.
Taint-style thinking preceded framework enforcement
Even without relying on a particular framework mechanism to mark sensitive values, I treated several types as unable to cross the client boundary: raw policy records, database rows, model credentials, internal source paths, and unrestricted document objects.
Public constructors produced safe descriptors. Code review and architecture tests rejected object spreading from internal models into props. Fixtures included hidden fields with conspicuous sentinel values and searched built output and responses for them.
This was a practical data-flow discipline. A future enforcement feature could strengthen it, but the product contract did not wait for one.
The safest sensitive prop was the one no public API could construct accidentally.
Server-side data access tempted me to cache every async read. K81R's values had different lifecycles:
- Immutable source revision content.
- Current document status and supersession.
- Request-scoped authorization.
- Active corpus and index generations.
- Derived relationship projection.
Immutable content could cache by source and revision. Current status needed invalidation when corpus authority changed. Permission-sensitive results could not enter a cross-subject cache. Relationship projections could lag if the page named their generation and failure remained local.
I wrote source, scope, key, freshness, and invalidation beside each query. “Runs on the server” did not mean “reads current data every time.”
The component model made caching close to rendering; it also made incorrect cache scope capable of leaking data close to rendering.
Cache ownership stayed below presentation
I avoided letting arbitrary components invent cache keys from props. The domain query layer exposed lifecycle-aware operations: get immutable revision, get current source status, get authorized source view, and get relationships through a named projection generation.
The route could compose these reads and choose streaming boundaries. Cache identity and invalidation remained with the data contract.
This reduced a class of bugs where two components cached the same source under different assumptions. It also made migration from the old route less dependent on one framework cache API.
Colocation did not require duplicating policy.
Async Server Components made it easy to place a Suspense boundary around every read. P6X4's 2022 experiment had already shown the result: architecture rendered as spinner theater.
K81R streamed:
- Disclosure-safe source identity, status, and authored content.
- Related decisions as one coherent secondary region.
- Optional query context tied to the active search generation.
The evidence selector's client code could hydrate after the source was readable. Its visual controls did not imply readiness before handlers existed.
The server/client boundary and loading boundary were independent. One described execution ownership; the other described an understandable reveal.
Composition could cross the boundary without client ownership
A client interaction shell could receive server-rendered content as children. This allowed a collapsible evidence panel to own open state while the evidence document itself remained server-rendered.
I used the pattern carefully. The child content had already been produced on the server and crossed as component output, not as a raw document object the client had to interpret. The shell knew layout and interaction, not source parsing.
This avoided duplicating server content logic inside the client island. It also kept the client API narrow: label, default state, and rendered content.
Composition became a way to separate interaction ownership from content ownership.
Selecting a passage began with a browser event. Acceptance into an evidence set remained a server command with stable request identity, expected set revision, source passage identity, and authorization.
The client could update its draft optimistically and show pending state. It could not declare the evidence set saved. A conflict response reconciled local operations with the accepted server revision.
This prevented “client component” from becoming “client authority.” The interaction crossed the network through the same explicit protocol as the old route.
Rendering architecture changed. Durable intent semantics did not.
Forms provided a baseline
The source route remained readable without JavaScript. Evidence selection used ordinary form semantics for a baseline path, enhanced by the client tray for multi-select editing.
This influenced component design. The server-rendered passage already contained a stable source and passage ID. The client enhancer attached richer behavior instead of reconstructing the document from a JSON model.
Navigation, citations, and source inspection used links. Only genuine local interaction depended on hydration.
Progressive enhancement reduced the practical cost of client code arriving late or failing.
Hydration errors became smaller and more local
The old full-page hydration could fail because one browser-only value changed markup deep in the tree. The new route hydrated small interaction roots with explicit props.
I still tested time, locale, random IDs, invalid nesting, and browser-extension-like DOM changes. Server and client components had to agree at each client boundary. A mismatch in the evidence tray did not make the source body unreadable.
Stable IDs came from source and evidence identities, not render order. Locale-sensitive display was computed consistently or deferred where necessary.
Reducing hydration surface did not eliminate mismatch risk. It reduced the part of the page that could suffer from it.
Error boundaries followed authority and optionality
Source authority failure invalidated the main document. Relationship projection failure did not. Client selector failure left source content and ordinary navigation intact but made evidence editing unavailable.
The route placed error boundaries around those consequences. A component's server or client location did not decide how broadly its failure should render.
Errors carried source, revision, route, runtime role, release, and request identity where safe. The UI described product impact: “Related decisions are unavailable,” not “Server Component failed.”
The new execution model changed diagnostics. It did not change the reader's vocabulary.
Server component tests verified authorized queries, rendered document structure, source revision, metadata, and error containment. Client component tests verified selection state, keyboard behavior, focus, optimistic draft, conflict handling, and form fallback.
Route scenarios assembled both through actual navigation under slow streaming, delayed JavaScript, denied sources, changed authority, and optional failures. Build inspection checked client dependencies and payloads.
I avoided mocking every server component into a client-style unit. The value of the architecture came from responsibilities being different.
Tests followed those differences and met again at the product route.
I compared the established and migrated source routes using the same synthetic fixtures:
- Client JavaScript transferred and parsed.
- Serialized data crossing client boundaries.
- Time to readable source identity and content.
- Time until evidence selection responded.
- Layout movement during optional streaming.
- Server render and query time.
- Navigation behavior on warm and cold paths.
The migrated route sent less client code and less duplicated data. Reading became useful before the selector hydrated. Server work increased modestly in one navigation case, which caching and query composition had to manage.
The result was a trade made visible, not a claim that Server Components were automatically faster.
Browser memory exposed an unexpected benefit
The old route retained parsed document nodes and relationship objects in client memory after hydration. Navigating through several large sources increased heap use until the router released old page state.
The server-first route sent rendered output and small interaction descriptors. Client memory tracked selected IDs, local filters, and navigation state rather than the full source model.
I tested long documents and repeated navigation under a browser performance profile. The improvement reinforced the bundle result: browser responsibility includes retained data, not only JavaScript bytes.
This mattered on constrained devices more than the desktop development environment suggested.
Moving code out of the browser could move too much computation into every server request. Parsing immutable source on each render was wasteful. Relationship queries in several components could duplicate work.
I moved source parsing to the versioned corpus pipeline, loaded immutable structured views, shared request-scoped reads where appropriate, and measured server CPU and query counts. Expensive optional relationships streamed and cached under their projection generation.
The goal was not to maximize server execution. It was to place work where authority, reuse, device cost, and interaction made sense.
Server Components made server work easy to express. Measurement kept it intentional.
Shared UI primitives became more honest
Some components carried client directives only because a design-system index exported one interactive sibling. I separated server-safe presentation primitives from interactive compositions without forking their visual contract.
Typography, status, layout, source blocks, and links remained environment-neutral. Dialogs, selection groups, and browser preference controls declared client capability. An interactive component could compose server-rendered children when content did not need client ownership.
This improved J05N's old adoption lesson. Reuse followed behavior and migration constraints, not a universal implementation environment.
The component library became clearer about which capabilities cost browser code.
Some work that previously appeared in browser traces moved to server spans. I created one navigation identity across server route work, streamed regions, and client readiness. The page reported source-readable and selector-ready as separate milestones.
Cancelled navigations did not become unexplained server failures. Server query errors linked to domain state. Client interaction errors linked to the evidence-set draft and source identity without copying sensitive content.
I could answer both “Why was the source late?” and “Why could the person not select it?”
Execution moved; the incident narrative remained connected to the task.
Incremental adoption protected the learning loop
K81R's source route moved first. The evidence workspace, which contained denser client interaction, stayed largely client-owned. Confirmation and static utility routes moved only if the new structure simplified them.
Old and new routers coexisted during a bounded comparison period. URLs, domain queries, design primitives, and command protocols remained shared. The migration ledger required deletion of the old route before calling a move complete.
This avoided turning “server-first” into a site identity. Different pages purchased different amounts of browser responsibility.
At first I treated client directives like warnings. The resulting design pushed unrelated interactions into large shells and made code harder to understand.
Browser state, focus, input, selection, clipboard access, and resilient optimistic interaction were genuine responsibilities. A well-designed client component made those responsibilities coherent and accessible.
The architecture improved when I stopped minimizing a syntax marker and started minimizing unexplained browser ownership.
A client boundary was successful when a reader could name why it existed.
A Server Component was still interface code
The source renderer shaped information hierarchy, headings, loading sequence, error disclosure, links, metadata, and accessibility. It ran on the server and remained frontend engineering.
This complicated the old location-based definition that frontend meant code executing on the user's device. The discipline owned the experienced boundary across server and browser:
- What information arrives first.
- What the browser must download and retain.
- Which interactions require local state.
- How errors and stale data are communicated.
- How authorization affects rendered disclosure.
- How navigation and focus behave over time.
Execution environment became one design dimension inside frontend responsibility.
Server-first did not mean server-only
K81R's best result used both environments deliberately.
The server owned authorized source access, document structure, current status, metadata, and initial rendering. The browser owned transient selection, focus, filtering, copy feedback, and enhanced form flow. The server accepted durable evidence-set revisions. Both participated in navigation and progressive delivery.
The boundary could move as a feature changed. If the source view gained a local annotation canvas, browser responsibility would grow. If an interactive filter became a URL-driven server query, it might shrink.
Architecture described current needs, not a permanent ideology.
The universal JavaScript question returned in a better form
In 2016, I had asked why server-rendered content should require the browser to download and reconstruct the same application before becoming fully useful. Server Components did not make every old problem disappear, and they introduced new caching, rendering, and mental-model work.
They offered a component-level way to decline part of the browser inheritance.
K81R's source page still used React throughout. Much of that React no longer became client JavaScript. Components remained a composition language across the product boundary instead of a guarantee about execution location.
The lasting question was no longer “Should this page use JavaScript?” It was more precise:
Which capability, code, data, and lifecycle must become the browser's job for this part of the interface to work?
Server Components moved the boundary close enough to each component that I had to answer.
I added three limits to the route contract: maximum client JavaScript for a cold source view, maximum serialized data entering client roots, and maximum number of client-owned source nodes. They were generous enough for accessible interaction and strict enough to catch a broad document object or heavy dependency entering through convenience.
A limit breach did not fail solely on bytes. The report named the importing component, dependency path, payload field, and change from baseline. A reviewed interaction could raise the budget with a reason and corresponding performance scenario.
This kept the boundary observable after the migration article was written. Architecture that depends on continual restraint needs feedback where erosion occurs.
The absence of JavaScript was verified
It was not enough for source components to lack a client directive. I inspected the produced client manifests and browser network under a cold load. Source parsing, document relationships, policy records, and syntax-rendering implementation were absent. Only the selector, copy helper, navigation runtime, and their shared UI dependencies arrived.
With JavaScript blocked, the source still rendered in correct reading order, links worked, and the baseline selection form submitted. With the server unavailable after initial load, no interface pretended current source or authority could be reconstructed from an old client model.
The test made both sides of the boundary concrete: what the browser received and what it could no longer do alone.