Oh boy, we're in for a ride today! Those were 2 intense months, but I learned more about React and its inner workings than I ever thought I would (and should). This is Part 1, about the wrong turns, the breakthroughs, and how the framework came to be. Part 2 takes the implementation apart.
Oh boy, we're in for a ride today!By the end, you should know what a tiny framework built from scratch has to do, including how to create your very own Hydration Mismatch error! ðŸ«
NOTE:
This project was my attempt to understand and demystify the React 19 canary APIs I had been using. It was not meant to be the next (no pun intended) big meta-framework, only the simplest implementation of a framework with React Server Components that I could understand.
Amidst the downpour of a Saturday, I obtained my coffee... Ok, leaving the BS aside, my latest "project," htnx, got me curious about how RSC and Server Actions handled every miraculous thing I tried to make them do. Almost a year earlier, I had seen Leah Lundqvist build the Marz React framework from scratch with Bun, so I thought: "How hard can it be? I should get this done by the weekend." (Spoiler alert: it was waay harder than I expected.)
With a new Bun project running, I already had TypeScript support, a web server, a bundler, and a transpiler. Only then did the real question arrive: "What do I actually need to do here? What does React handle, what belongs to the framework, and how do I glue the two together?" I could not find documentation for framework authors, even though Next.js already supported RSC and Marz had it working a year earlier. So I started digging through their source code, and I mean really digging.
Have you ever looked into the Next.js codebase? It's huge, and I mean huge! That's not the best sign for someone trying to implement similar functionality in under 100 lines. (Had I mentioned that target before? It was a questionable one, that's for sure.)
I will not lie, that quick glance at the Next.js codebase scared me, so Marz looked like a better place to start. It was much smaller, but still a bit too much for someone with literally no idea what it was doing. I dug through its history and, between the funny commit messages and a few curses, found the first implementation that seemed to work. That became my starting point.
Ok, now we have the simplest implementation I could find, but how does it work? Why are there two servers? I can do it in one... (This decision comes back to haunt me later; we'll get there.)
This was when a few things started to click. React exposed server-specific pieces, but I still did not understand how the RSC and SSR sides were meant to coexist. (That part took weeks and a few DMs with @joshcstory.)
The React repository contains several react-server-dom-* packages, with integrations for specific module systems and bundlers. Reproducing a complete Webpack or Turbopack integration was far beyond this project's scope. I followed the Webpack version because it appeared in most of the small examples I had found, but without Webpack, I was polyfilling everything that got in my way.
This was fine for a while. Marz took a similar approach, so I knew it could work, but working and working well are 2 different things. The DMs gave me a clearer picture: the Webpack integration did not implement the Server Action bundler hooks I needed. Josh put it plainly: "the Webpack one doesn't implement server actions in the bundler config". Time to go back to the drawing board.
working and working well are two different thingsBy coincidence, a few weeks earlier I had seen Dan discuss how Tanner could support React 19 in TanStack Start. He dropped a link, and a new world opened up: "What's that, fixtures... Flight... flight-esm... WAIT, IS THAT IT?" It was (kind of) what I needed. I jumped into Dan's DMs to clarify a few things and thank him for the accidental help, but then I learned something.
NOTE:
Until this point, I had pinned an 8-month-old canary build. Encouraged by the fixture, I updated to the latest canary, which expected the runtime to support the
react-serverexport condition. Guess how happy I was to discover that Bun did not support it yet... 😅I found an open PR, and Jared and the Bun contributors moved quickly. One release later, the condition was supported, so we were back in business!
I had never seen react-server-dom-esm on npm. Looking again, it was not published there (at least when I wrote this), even though its source lived in the React repository. Humm...

Josh had mentioned the ESM implementation before, and now he explained why it was not published: "the ESM one is just not production grade b/c it's not a bundler and would be wildly inefficient in prod". Dan confirmed the tradeoff: "the downside of the ESM one (why we don't publish it) is that it's going to be super inefficient. loading files one by one. bundler is better". So, back to the drawing board again? Wait...
Let me see if I had this right: the ESM implementation was not production-ready because it had no bundler integration and loaded modules one by one through a custom Node.js loader. That loader would not work in Bun anyway, but what if I did the bundling myself? Then I could use its modules and helpers without paying the same per-file runtime cost. That sounded like a plan.
IMPORTANT:
Because the package was not available on npm, I had to clone React and build it locally. For the first time, I saw my M-series MacBook get hot, and I mean hot!
Twenty minutes later, I had the package and a working fixture showing how it was supposed to behave.
With all of that in my hands, I started a quick refactor.
I was using Bun.serve, but React's ESM fixture exposed a pipeable Node.js stream, while Bun's response API expected a Web ReadableStream. I could have added a conversion layer, but I was not ready for that rabbit hole. The server I already knew how to pair with Node streams was good old Express. Not ideal, but it worked.
express()
.use(logger) // A simple logger middleware
.use(cors) // Default CORS middleware
.get("/*", async (req, res) => { ... })
.listen(port)After a few more changes, I reached what looked like the best version so far. It rendered pages and some client components, but renderToPipeableStream, the function creating the RSC payload, threw an error because useReducer was unavailable. My 999 IQ brain concluded: "I should just patch that; React has to be wrong here!"
.get("/*", async (req, res) => {
// ...
renderToPipeableStream(mod, moduleBaseURL).pipe(res)
})A trail of console.logs later, I found the function, wrapped it in try/catch, ignored the error, and thought: "My work here is done!" Client and Server Components were rendering, actions returned results, ESM modules loaded, and the console was quiet. Then Dan asked: "you're not doing SSR for now, right?"
Ok, I did not know I wanted this, but I absolutely wanted SSR too. Server Components can be rendered into HTML for the initial response, so leaving that out meant leaving out half the picture. But how hard could it be? (This question was starting to haunt me.)
Surely I only needed to take the method used by _client.tsx, call it in the endpoint, and send the generated... HTML? That was when I remembered the thing I had been producing was not HTML at all.
After a reasonable break, I came back to it. What I had been generating all along was not HTML but the RSC payload, which looked something like this:
2:"$Sreact.suspense"
3:I["components/counter.js","default"]
1:D{"name":"os_default","env":"Server"}
1:["$","b",null,{"children":["darwin"," ","arm64"]}]
4:{"id":"/build/components/actions.js#add","bound":null}
0:[["$","main",null,{"className":"m-4 border-4 border-dashed border-red-400 p-4","children":[["$","h1",null,{"className":"text-2xl font-bold","children":["Hello from ",["$","i",null,{"children":"node"}],"! "]}],["$","p",null,{"children":["You're running: ","$1"]}],["$","section",null,{"className":"mt-4 flex h-16 items-center justify-center border-4 border-dashed border-blue-400","children":["$","$2",null,{"fallback":"Loading counter...","children":["$","$L3",null,{"action":"$F4"}]}]}],["$","nav",null,{"className":"mt-4 [&_a]:text-blue-500 [&_a]:underline","children":["Follow to: ",["$","a",null,{"href":"/props?name=John&age=25","children":"/props"}]]}]]}],["$","footer",null,{"className":"mx-4","children":[["$","h2",null,{"children":"Caption:"}],["$","small",null,{"className":"text-red-400","children":"* Server components = No bundle size increase, rendered on the server"}],["$","br",null,{}],["$","small",null,{"className":"text-blue-400","children":"* Client components = Includes a JS bundle, rendered on the client"}]]}]]React's client runtime knows how to reconstruct a tree from this payload, but I did not. I now needed to turn that tree into HTML, and I had no idea how.
Back in the fixture, I saw two servers, one calling the other for reasons I still did not understand. The second stage used a method from react-dom/server that looked like the missing piece.
I dropped that snippet into my code and got error after error: "'use client' is not supported," "you cannot call hooks during the first render," and more. The export condition worked, and the ESM renderer worked, but I still could not produce HTML. I was not ready to give up, but I was close enough to message Josh again. (I'm sorry, Josh. I owe you a coffee.)
Josh came again to the rescue. After I explained the problem, he said:
"So one thing to keep in mind is that you need two module graphs when you want to run RSC and SSR in the same process. When you have a React program that imports from react-dom and react, we want the version of those modules to be the RSC version. When you have a React program that imports from react-dom and react for SSR (client components), you need those modules to be the non-RSC version. It sounds like you probably have just one version of these modules in scope, and so when your client components try to access React for useReducer, they get the RSC version, which doesn't have that export."
WAIT, WHAT? OH NO... IS THIS WHAT THAT FLAG DOES? AAAAAAAHH.

Man, what a sweet moment. I remember it vividly. I was leaving the office when the message arrived, got into the car, and started laughing. I had the solution all along but had never stopped to think about what the react-server condition selected. I rushed home, split the runtime into an RSC server and an SSR server, did the server-calls-server dance, and... it worked. I had a working React 19 framework, and I was so, so happy.
Of course, this was not all. There was plenty of refactoring, cleanup, and testing left, but I had a small React 19 framework with ESM modules and a build step, and I was proud of it. I learned a lot about React's internals, framework responsibilities, ESM, and module maps. I also met incredible people who were generous with their time. I am grateful for all of that.
If you made it this far, Part 2 goes through the code, the build step, and the two-server runtime.
CAUTION:
The technical side is next, including everything you need to create your very own Hydration Mismatch error. ðŸ«
latest commit : 3 additions 110 deletions