Server components and Bun

Colin McDonnell · April 20, 2023

Bun's bundler has the benefit of being designed & conceived in a post-server-components world. While React published the first fully functional implementation of server components, the patterns proposed by the React team are designed to be library-agnostic.

How server components work

As a quick refresher:

  • The "entrypoints" to your application are server components. Like "regular" components, these are functions that accept props and return JSX.
  • Unlike "regular" components, they can be async, can't use "stateful" hooks like useState/useEffect, and only ever "render" on the server.
  • All components are interpreted server components by default, unless the "use client" directive is present at the top of the file in which they are defined. (This convention is still evolving and may differ between frameworks.)

The conventions here are still evolving, but in most server component setups, all components are interpreted as server components by default, unless the "use client" directive occurs at the top of the file.

Here's a simple example of a server component Page that imports a client component UserProfile:

import {UserProfile} from "./UserProfile.ts"; // a client component

export default async function Page(){
  const user = await db.getUser(123);
  return <div>Hello {user.name}</div>
"use client";

export function UserProfile(user: {name: string; avatar: string}){
  return <div>
    <img src={props.user.avatar} />
    <p>Name: {props.user.name}</p>

Different frameworks may have different conventions here. For instance, the components inside, say, a components/ directory might be interpreted as client components regardless of whether the "use client" directive is present.

Bundling server components

The complexity arises from the fact that converting these two simple files requires no less that three separate bundling steps:

  1. The "server component" bundle contains the App component. Server components treat client components as "black boxes". When you "render" a server component, the result is a JSX component hierarchy that cosiders into stub functions. Server components treat client components as black boxes during rendering.
  2. The "server-side rendering bundle that contains a bundled version of UserProfile intended for consumption during the SSR step.

The example above seems simple, but bundling it isn't trivial. Let's consider the lifecycle of an incoming HTTP request to this app.

  • A Request comes in, and we want to return some HTML. We need to "render" our App server component. To do so, we need to first bundle our pages/index.ts file, then we can call the App function contained inside. We'll call this the "server build".

  • But there's a wrinkle. Perhaps unintuitively, server components do not render client components; in fact, client components are treated as "black boxes". When you render a server component, the result is a component hierarchy, with client components as the terminal nodes in the tree.

    We need to convert that virtual DOM tree into a regular old component tree. React provides a special function to do this called createFromReadableStream, but it requires you to provide a manifest that is used to "look up" and client components in the virtual DOM and replace them with their actual contents. For that, we need to perform separate a build of UserProfile.tsx, so we can then pass a reference to createFromReadableStream. We'll call this the "SSR build".

    At this point, we finally have a regular old component tree and we can render that to HTML using ReactDOMServer.renderToString.

  • At this point we have HTML that can be sent to the client. But to make the app interactive, we still need to hydrate that HTML so our client components can take over. For that, we need to create a third bundle containing all of our client components that targets the browser. We'll call this the "client build".

    This build is particularly strange since we don't know which client components will be used by our server components ahead of time. To determine which components to build, we need to start doing the "server build" and keep our eyes peeled for "use client" directives. When we find one, we add it to the list of components to build.

Bun's bundler has this logic baked in. Just pass in your server component entrypoints with the --server-components flag, and everything else is handled for you. The bundler will automatically perform the server, SSR, and client builds. You can then read the returned manifest to know exactly where everything is.