A Thought Experiment in HTML Evolution
Imagine you’ve only ever seen one piece of HTML in your life—a simple paragraph, say. If you had complete design freedom, where would you start adding features? For Dan Abramov, the answer begins with a surprisingly simple idea: the ability to define your own tags.
Server Tags: The First Primitive
The first feature worth adding is user-defined tags, implemented as plain JavaScript functions. When HTML is serialized for the network, the server replaces any custom tags with the values their functions return. Once no tag functions remain, the HTML is ready to transmit. This single choice—making tags executable at serialization time—shapes everything that follows.
Attributes and the Rise of Objects
Supporting attributes leads naturally to passing arguments into those tag functions. There’s no reason these arguments must be strings; passing an object groups related values together. But that raises a question: what do we do with those objects? Traditional HTML would force a style object into a string. In this imaginary HTML, we instead specify that serialization outputs a JSON tree, a format strictly richer than today’s HTML because it can carry objects—and any other values—intact. This JSON can always be converted into “real” HTML later if desired.
Asynchronous work fits in cleanly. Reading from a filesystem introduces await, so the spec is amended: the server must await any async function tags it encounters. The output remains the same structured JSON, ready for conversion. At this point, a tag like Greeting is not just a “paragraph”—it’s a component that owns its data fetching and output.
Deferring Work: Client References
Executing a function tag during serialization removes it from the payload. But what if we want a function to run later, on click? We need a way to transfer functions over the wire without ambiguity about where they execute. Passwords as strings are unmaintainable, and JSON.stringify silently omits functions—so the spec throws an error when encountering a function outside of a tag position.
The solution is a Client Reference: a string like '/src/bundle.js#onLike' that acts as a function’s “address.” To write this naturally, we split our code. The server half imports from a module marked with the 'use client' directive—syntax that says, “when you import me, you get a reference, not the implementation.” This turns <script> tags into a first-class part of the module system, no longer a special HTML element.
Exposing Server Logic: Server References
Client code referring to server code uses the inverse pattern. A Server Reference—such as '/api?fn=onLike'—lets a client call back into server code, typically via a POST fetch(). Here there’s no need to split files, since both sides run on the server. The 'use server' directive marks functions as serializable into these references, making API calls themselves a module-system feature with options for progressive enhancement.
Client Tags and Composition
An interesting consequence emerges. If a Client Reference is used as a tag, the server—which must execute all functions it encounters as tags—sees only a reference, not a function. Execution is deferred completely. These Client Tags appear in the JSON untouched, ready for a client-side interpreter to handle.
This enables full-stack composition. A server-side PersonalizedLikeButton can wrap a client-side LikeButton. During serialization, server tags run and leave only their output; during deserialization, client tags run and produce DOM or HTML. Components can span both sides of the wire, encapsulating state and logic. Crucially, client and server code never mix in one file; they merely reference each other via directives, avoiding “stringly” conventions and making coupling typed and modular.
Navigation and Streaming
Since the primary output is JSON, a client-side <Router> tag can intercept navigations, fetch the next screen’s JSON—a single roundtrip that executes all server tags—and apply it to the DOM without destroying client state. Nested routers could handle granular refetching.
Blocking on every async server tag is undesirable, so the spec permits serializing outside-in with “holes” for unresolved content, streamed in later. A <Placeholder> primitive declares fallback UI without dictating semantics: a static blog generator might buffer everything for no perceptible loading, while a dynamic server can emit the fallback HTML immediately and patch in the final content later, creating intentional loading states (blank page → page with shell and post content → comments). Nested placeholders offer fine-grained control over what blocks the screen.
The Payoff: Composability and Caching
This JSON format excels where HTML with inline <script> tags struggles. Repeating a client <Counter> three times with different data is trivial; repeating script tags would introduce clashes. More significantly, the format separates static structure and dynamic data cleanly. A complex interactive list (like this blog’s sortable post index) serializes into JSON that describes everything: what client code to download, where to find it, and what data each tag receives. This tree can be converted into SPA-only scripts or full server-rendered HTML.
That structure is ideal for server-side caching. Unlike cached HTML partials—which mix data and code inextricably—each piece of this JSON is independently cacheable, can contain “holes” for frequently-changing content, and preserves absolute data/code separation.
What emerged is a functional, programmable, and composable HTML: tags runnable on both sides of the wire, objects as first-class citizens, and code references replacing both <script> tags and fetch() calls. Taken together, these primitives are, in essence, React Server Components—viewed not as a framework but as a natural evolution of the web’s core markup language.



