Introduction to Universal Rendering with React and Bun

Universal rendering, also known as Isomorphic applications is a technique where the same components are rendered on both the server and the client. What does it mean?? I will explain in the following article where we will deep dive on how we can implement universal rendering.

Why do we need universal rendering at all?

The main use case of universal rendering is to improve core vitals of the site, such as (faster) Time to Interactive (TTI), Time To First Byte (TTFB) and First Contentful Paint (FCP). While core vitals are not the only performance metrics that are important, optimizing for these metrics helps us deliver a smooth, hassle-free and delightful UX to the end user. Better SEO performance helps in ranking your page above others, and that’s where universal rendering helps us. Although universal rendering is not a silver bullet to solve all of these problems as software engineering is about trade offs, sometimes we try to optimize for faster FCP, for example in the case of ecommerce storefront serving their product catalogue, your TTFB might take a small hit while fetching those images and painting them on the server. TTI optimization depends on the use case as interactivity depends on hydration of the javascript content and code splitting optimizations per se. But in this article our focus will be on how to implement isomorphic / universal rendering with bun and react.

Getting started

To get started with the project, if you’re following along, I would suggest to download bun if you have not already done it. Bun is an ultra fast JavaScript/Typescript runtime, package manager, test runnner, module bundler, and everything else that you need for building full stack JS/TS apps. It is a drop in replacement of Node.js and it aims to be a unified toolkit for the JavaScript and TypeScript ecosystem. Bun is built on Web Standards that means the APIs we have on the browser can also be used on the server. Typescript and JSX/TSX are first class citizens in bun, meaning we don’t need a bundle step for transpiling JSX into JS. Alright, without further ado, let’s start with the implementation.

In this implementation of ours, we will not use any framework, we will work with the first principles while creating our http server and everything in between although bun greatly solves some of the hard parts for us!

If you want you can clone the repo I have attached, or you can follow along.

bun init
✓ Select a project template: Blank

 + .gitignore
 + index.ts
 + tsconfig.json (for editor autocomplete)
 + README.md

To get started, run:

    bun run index.ts

After selecting the blank template, you will have the following files mentioned above.

Now in the index.ts file, let’s create add a basic boiler plate code that starts up our http server and responds with a “Hello World” string.

const server = Bun.serve({
  port: Bun.env.PORT || 3007,
  async fetch(req) {
    return new Response("Hello World");
  },
});

console.log(`Server running on http://localhost:${server.port}`);

When you run this bun run index.ts, it starts up a http server and responds with a Hello World string. You can try that yourself.

Now let’s add some scripts to our package.json file

{
  "name": "react-universal-rendering-demo",
  "module": "index.ts",
  "type": "module",
  "private": true,
  + "scripts": {
  +  "dev": "bun --hot src/server/index.ts"
  + },
  "devDependencies": {
    "@types/bun": "latest"
  },
  "peerDependencies": {
    "typescript": "^5"
  }
}

The dev script has a flag called —hot flag does soft reloading which means the process is not restarted, which means that all the environment variables and stuff that remain in the context are still used.

Now let’s refactor our codebase and move our index.ts file into src/server folder, along with that let us also create 2 more folders called client and shared I will tell you the significance of these folders soon.

When we talk about isomorphic react app, as the name itself suggests, the structure of the application is the same irrespective of the environment, which means same routes that you have on the client from a UI perspective are there on the server as well, so there is no concept of UI handling the routes differently than the server. With this in mind we have designed our folders in such a way to resonate this thought process.

  • The client folder is where we will have our hydration handlers so that once the route is rendered as html on the server, the interactivity is achieved by hydrating the javascript of that route in the browser. This happens when that route’s html page requests the server for its corresponding js build output.

  • The shared folder is where we have our routes and components required to build the application.

  • The server folder is where our http server resides to handle user’s requests.

Now we shall create the demo pages/routes required for our application. We’ll install react, react-dom v18 and react-router v6.

// src/shared/pages/HomePage
import React, { useState } from "react";

const HomePage = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="page home-page">
      <h1>Welcome to our Isomorphic React App!</h1>
      <p>This page is rendered on both server and client.</p>
      <div>
        <button onClick={() => setCount((prevState) => prevState + 1)}>
          Add
        </button>

        <p>count: {count}</p>

        <button
          disabled={count <= 0}
          onClick={() => setCount((prevState) => prevState - 1)}
        >
          subtract
        </button>
      </div>
    </div>
  );
};

export default HomePage;

We’ll create an About Page now.

import React from "react";

const AboutPage = () => {
  return (
    <div className="page about-page">
      <h1>About</h1>
      <p>This is an isomorphic React application powered by Bun.</p>
    </div>
  );
};

export default AboutPage;

And now we will create a Not found page

import React from "react";

const NotFoundPage = () => {
  return (
    <div className="page not-found-page">
      <h1>404 - Page Not Found</h1>
      <p>The page you are looking for does not exist.</p>
    </div>
  );
};

export default NotFoundPage;

Now let’s create a components folder in the shared folder and we’ll add Navigation component.

import { Link } from "react-router";

const Navigation = () => {
  return (
    <nav className="navigation">
      <ul>
        <li>
          <Link to="/">Home</Link>
        </li>
        <li>
          <Link to="/about">About</Link>
        </li>
      </ul>
    </nav>
  );
};

export default Navigation;

To wire up all these components we’ll create App.jsx and add the routes in that folder. Add it in the root of the shared folder.

import React from "react";
import { Routes, Route } from "react-router";
import HomePage from "./pages/HomePage";
import AboutPage from "./pages/AboutPage";
import NotFoundPage from "./pages/NotFound";
import Navigation from "./components/Navigation";

const App = () => {
  return (
    <div className="app">
      <Navigation />
      <main>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
          <Route path="*" element={<NotFoundPage />} />
        </Routes>
      </main>
    </div>
  );
};

export default App;

Finally in the client folder we need to a way to attach our event handlers. This process of loading javascript into a webpage is called hydration, hydration makes the application interactive.

import { hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "../shared/App";

// Client-side hydration
const container = document.getElementById("root");
if (container) {
  hydrateRoot(
    container,
    <BrowserRouter>
      <App />
    </BrowserRouter>
  );
}

// Notify when hydration is complete
console.log("🚀 Client-side hydration complete!");

Now we have reached a critical point, in an isomorphic application, when the user first requests a route/page to the server, the initial content is SSR’d and then sent to the client after which hydration occurs and the page becomes interactive, from then on, any subsequent interaction or routing feels fast because the client bundle has already been loaded into the user’s browser, so unless the user requests another full page reload or hard refresh, Client side routing takes place. If the user performs a hard refresh then the server responds back with the corresponding html and hydration again takes place. Although, you might ask what if there are a lot of routes or lot of code complexity in the client side bundle, doesn’t hydration become slow? The answer to this question lies in code splitting and import optimization strategies like lazy loading, dynamic imports, tree shaking, We could also utilize island architecture as popularized by jason miller. I have attached the links in this document if you want to explore more on these topics.

To answer some of you questions we’re gonna build the critical part of our http server that actually serves the requests and the UI!

import { renderToReadableStream } from "react-dom/server";
import { StaticRouter } from "react-router";
import App from "../shared/App";

const server = Bun.serve({
  port: 3006,
  idleTimeout: 30,
  async fetch(req) {
    const url = new URL(req.url);

    console.log("Request URL:", url);

    if (url.pathname.startsWith("/api")) {
      const responseBody = {
        version: `Server ${Bun.version}`,
        timestamp: new Date().toISOString(),
        path: url.pathname,
        message: "Welcome to API Route",
      };
      return new Response(JSON.stringify(responseBody), {
        headers: { "Content-Type": "application/json" },
      });
    }

    return new Response("Hello World", { headers: { "Content-Type": "text/plain"} })
});

console.log(`Server running on http://localhost:${server.port}`);

We have a simple route which responds with the current Bun version and the url path and a timestamp, requests starting with /api are handled by the if block, of course in a real world application we’d have a framework managing the http routing for us though!

Now what do we need?

We need to do the following things with our http server:

  1. If there are static assets, we need to serve static assets

  2. We need to render our application on the server and send it to the client

How do we achieve 1 and 2? Well, to achieve 1, you need to check the route and if it contains anything from build folder we serve it.

    // Serve static files from public directory
    if (url.pathname.startsWith("/build/")) {
      const filePath = `./public${url.pathname}`;
      const file = Bun.file(filePath);

      if (await file.exists()) {
        return new Response(file);
      }
    }

Do note that bun makes it very easy for us to serve files with developer friendly APIs, since our build folder is being server in public folder, we prefix the path and then serve the file.

How to achieve 2? Here is our strategy, react provides us with 2 utils namely renderToString, and renderToReadableStream, and also renderToPipeableStream (for node.js).

renderToString takes a react tree and outputs a string version of the DOM tree, but this method is blocking, however, renderToReadableStream is not blocking and it allows the server to start sending HTML to the browser before the entire page is ready, improving initial load times. We can utilize Suspense boundary to show intermediary UI such as loading states before stream the actual component.

try {
      const htmlStart = `
      <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Isomorphic React App with Bun</title>
      </head>
      <body>
      <div id="root">`.trim(); // using trim method to avoid hydration mismatch error

      const htmlEnd = `
          </div>
        </body>
      </html>
      `.trim();

      const stream = await renderToReadableStream(
        <StaticRouter location={url.pathname}>
          <App />
        </StaticRouter>,
        {
          bootstrapScripts: ["/build/index.js"],
          onError: (error) => {
            console.error(error);
          },
        }
      );

Notice a couple of things here

  1. we have a html tagged template

  2. renderToReadableStream function.

The renderToReadableStream method takes a React element and returns a Promise that resolves to a Readable Stream, which can be used to stream HTML to the client as it's generated. Additionally we can give an options object to it to attach hydration scripts to the generated html.

If you notice the StaticRouter takes a prop called location, which is passed as url.pathname The StaticRouter matches only a single pathname and renders it alone, so if /about page is requested, only that component tree is rendered, similarly for other routes as well.

      const combinedStream = new ReadableStream({
        async start(controller) {
          const encoder = new TextEncoder();
          controller.enqueue(encoder.encode(htmlStart));

          const reader = stream.getReader();
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            controller.enqueue(value);
          }

          controller.enqueue(encoder.encode(htmlEnd));

          controller.close();
        },
      });

      return new Response(combinedStream, {
        headers: {
          "Content-Type": "text/html",
          "Transfer-Encoding": "chunked",
        },
      });
    } catch (error) {
      console.error("Failed to render:", error);
      return new Response(`Server Error: ${error.message}`, { status: 500 });
    }

In this part of the snippet we are using ReadableStream function to stream the response to the client, we’re queueing the chunks and when the chunk is ready and combing them and finally sending them back to the client. We used text encoder because streams work with binary data (UInt8Array) rather than the string itself.

Another crucial part we need to realize is that browsers CAN understand incomplete HTML, it is not necessary to have perfectly structured html to be sent the client, the browser is designed in such a way that it can show whatever is available at the point in time while waiting for the other chunks to arrive. We utilize this concept to approach server side rendering.

To sum it up we have the following piece of code that works on the server

import { renderToReadableStream } from "react-dom/server";
import { StaticRouter } from "react-router";
import App from "../shared/App";

const server = Bun.serve({
  port: 3006,
  idleTimeout: 30,
  async fetch(req) {
    const url = new URL(req.url);

    console.log("Request URL:", url);

    if (url.pathname.startsWith("/api")) {
      const responseBody = {
        version: `Server ${Bun.version}`,
        timestamp: new Date().toISOString(),
        path: url.pathname,
        message: "Welcome to API Route",
      };
      return new Response(JSON.stringify(responseBody), {
        headers: { "Content-Type": "application/json" },
      });
    }

    // Serve static files from public directory - 1
    if (url.pathname.startsWith("/build/")) {
      const filePath = `./public${url.pathname}`;
      const file = Bun.file(filePath);

      if (await file.exists()) {
        return new Response(file);
      }
    }

    try {
      const htmlStart = `
      <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Isomorphic React App with Bun</title>
      </head>
      <body>
      <div id="root">`.trim();

      const htmlEnd = `
          </div>
        </body>
      </html>
      `.trim();

      const stream = await renderToReadableStream(
        <StaticRouter location={url.pathname}>
          <App />
        </StaticRouter>,
        {
          bootstrapScripts: ["/build/index.js"], // <script src="/build/index.js" /> is attached to the html document
          onError: (error) => {  // when the document loads, it requests this document http://localhost:3006/build/index.js
            console.error(error); // notice that our static assets are being served as mentioned in 1
          },
        }
      );

      console.log(stream);

      const combinedStream = new ReadableStream({
        async start(controller) {
          const encoder = new TextEncoder();
          controller.enqueue(encoder.encode(htmlStart));

          const reader = stream.getReader();
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            controller.enqueue(value);
          }

          controller.enqueue(encoder.encode(htmlEnd));

          controller.close();
        },
      });

      return new Response(combinedStream, {
        headers: {
          "Content-Type": "text/html",
          "Transfer-Encoding": "chunked",
        },
      });
    } catch (error) {
      console.error("Failed to render:", error);
      return new Response(`Server Error: ${error.message}`, { status: 500 });
    }
  },
});

console.log(`Server running on http://localhost:${server.port}`);

but we’ve forgot to add an important point, we need to bundle the client code.

{
  "name": "react-universal-rendering",
  "module": "index.ts",
  "type": "module",
  "private": true,
  "scripts": {
    "dev": "bun run --hot src/server/index.tsx",
    + "build:client": "bun build --watch src/client/index.jsx --outdir public/build"
  },
  "devDependencies": {
    "@biomejs/biome": "1.9.4",
    "@types/bun": "latest",
    "@types/react": "^19.1.4",
    "@types/react-dom": "^19.1.5"
  },
  "peerDependencies": {
    "typescript": "^5"
  },
  "dependencies": {
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "react-router": "^7.6.0"
  }
}

build:client script builds the frontend and it outputs into public folder.

I am attaching the repo here, you can clone the repo and run it and test it yourself.

This is how we do it, I hope this demo helps, in the upcoming blog, I will work on a much more detailed use case and walk you through developing server driven UIs from scratch.

In the next series I hope to cover caching, ISR, SSG and other such concepts which could be employed to develop fast and snappy UI.

I will see you folks later then 👋 Happy hacking.

0
Subscribe to my newsletter

Read articles from Yashwanth somayajula directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yashwanth somayajula
Yashwanth somayajula

I love teaching stuff, it is my passion, I love to build stuff for fun just as I love lego building blocks.