SSR with Bun, Elysia & React


This article will show you how to create a simple website using Bun, Elysia and React with support for server-side rendering.

  • Bun the JS/TS bundler, runtime & package manager
  • Elysia a framework for building performant web applications
  • React a JavaScript library for building user interfaces

Original source code here on GitHub.

Pre-requisites

Before we begin make sure you have Bun installed on your machine

curl -fsSL https://bun.sh/install | bash # for macOS, Linux, and WSL

Bun installation documentation

Next let’s initialize a new project with Elysia by running the following command in your terminal

bun create elysia your-project-name # edit this
cd your-project-name
bun run dev

Elysia installation documentation

You should see the following in your terminal

🦊 Elysia is running at localhost:3000

And the following when you visit http://localhost:3000

Hello, Elysia!

Project Structure

Next lets go ahead and define two more folders in our project which will hold our static files and react code.

mdkir public
mkdir src/react

You project structure should now look like this

β”œβ”€β”€ ./your-project-name
β”‚   β”œβ”€β”€ node_modules
β”‚   β”œβ”€β”€ public            # client-side static files
β”‚   β”œβ”€β”€ src 
β”‚   β”‚   β”œβ”€β”€ react         # react code
β”‚   β”‚   └── index.ts      # server-side entry point
β”‚   β”œβ”€β”€ .gitignore
β”‚   β”œβ”€β”€ bun.lock.b
|   β”œβ”€β”€ package.json
β”‚   β”œβ”€β”€ README.md
β”‚   β”œβ”€β”€ tsconfig.json
β”‚   └── yarn.lock

We want to be able to return static files from our server, so lets go ahead and add the following Elysia static plugin.

bun add @elysiajs/static

This will allow the client to request static files from our server such as images, css, js, etc. Now replace the contents of your src/index.ts file with

// src/index.ts
import { Elysia } from "elysia";
import { staticPlugin } from '@elysiajs/static'

const app = new Elysia()
  .use(staticPlugin())
  .get('/', () => {
    return 'Our first route'
  })
  .listen(3000)

The static plugin default folder is public, and registered with /public prefix. Click here to learn more

React Setup

The next thing we want to do is be able to render our React code on the server, so let’s go ahead and add the following dependencies

bun add react react-dom  # article is using 18.2.0

Next let’s create our React application by adding a file called App.tsx in our ./src/react/ folder with the following code

// src/react/App.tsx
import React, { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <html>
      <head>
        <meta charSet="utf-8" />
        <title>Bun, Elysia & React</title>
        <meta name="description" content="Bun, Elysia & React" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body>
        <h1>Counter {count}</h1>
        <button onClick={() => setCount(count + 1)}>Increment</button>
      </body>
    </html>
  );
}

React server-side rendering documentation

Now back in our ./src/index.ts let’s import this component by adding the following line

// src/index.ts
import App from './react/App'

Don’t worry if you see a type error, we will fix in the next section.

Fixing Type Errors

At this point you should see a couple type errors since we haven’t specified how our project should handle JSX and React types. To fix this add the following dev dependencies

bun add -d @types/react @types/react-dom

Next let’s specify how the project should handle JSX by opening our ./tsconfig.json and setting the following options

{
  "jsx": "react",
  "jsxFactory": "React.createElement",
  "jsxFragmentFactory": "React.Fragment",
}

For more information refer to the Bun JSX documentation. The type errors should now be gone, but sometimes you need to restart your IDE for the changes to take effect; looking at you VSCode…

React SSR

Now back in our ./src/index.ts let’s go ahead and render our React component by adding the following

// src/index.ts
import { Elysia } from "elysia";
import { staticPlugin } from '@elysiajs/static'
import { renderToReadableStream } from 'react-dom/server'
import { createElement } from "react";
import App from './react/App'

const app = new Elysia()
  .use(staticPlugin())
  .get('/', async () => {

    // create our react App component
    const app = createElement(App)

    // render the app component to a readable stream
    const stream = await renderToReadableStream(app, {
      bootstrapScripts: ['/public/index.js']
    })

    // output the stream as the response
    return new Response(stream, {
      headers: { 'Content-Type': 'text/html' }
    })
  })
  .listen(3000)

Make sure your local dev server is still running if you closed your IDE earlier by running the following from your project root

bun run dev

Then open http://localhost:3000 in your browser and you should see the following

React SSR

However, you may notice that pressing the button doesn’t increment the counter. This is because we haven’t added any client-side code yet. Let’s go ahead and do that now. Create a new file called index.tsx in your ./react/ folder with the following code

// src/react/index.tsx
// Make sure to include the following two lines:
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App.js'

hydrateRoot(document, <App />)

Normally this is where we would call createRoot, but since the root will be created on the server, all we need to do is hydrate the client after the initial load. This will attach event listeners to the server-generated HTML and make it interactive.

React Hydrate Documentation

/// <reference lib="dom" />
/// <reference lib="dom.iterable" />

Note these two lines above are not comments, but rather Triple-Slash Directives. These are used to tell the TypeScript compiler how to handle certain files. In this case, we are telling the compiler to include the DOM types in our project. This is necessary because the hydrateRoot function requires the DOM types to be present (i.e. document).

If you wish to access DOM elements (window, document, etc.) in another file just add these directives at the top of the file.

How to add DOM types in Bun

Bundle Client JS

We are almost there! The last step is to bundle our react code which will be loaded by the client. Luckily Bun has a built-in bundler which we can use to bundle our client-side code. We could do this via the command line or as a script in our package.json, but for this example we will do it programmatically in our ./src/index.ts file, just add the following at the top of the file

// src/index.ts
await Bun.build({
  entrypoints: ['./src/react/index.tsx'],
  outdir: './public',
});
That's right top level await!

Now our React code will be automatically bundled each time we start our server! You can verify this by checking if the ./public/index.js file exists. Make sure that this file path matches the bootstrapScripts option in your ./src/index.ts file.

// Make sure the bootstrapScripts matches the output
// file path in your ./public folder
const stream = await renderToReadableStream(app, {
  bootstrapScripts: ['/public/index.js']  
})

Now open http://localhost:3000 in your browser, the counter should now be working!

Conclusion

Congratulations you have just created a simple website using Bun, Elysia and React with support for server-side rendering!

If you have any additional questions feel free to reach out to me on Twitter or dropping me an email at colin@asleepace.com :)

Next Recommended Reading

Helpful Resources

The following links are helpful resources for learning more about the technologies used in this project:

If you have any additional questions feel free to reach out to me on Twitter or dropping me an email.

TL;DR

Here is the final code from the article, you can also view this project on GitHub.

Project Structure

β”œβ”€β”€ ./your-project-name
β”‚   β”œβ”€β”€ node_modules
β”‚   β”œβ”€β”€ public              # client-side static files
β”‚   β”‚   └── index.js        # generated client bundle
β”‚   β”œβ”€β”€ src 
β”‚   β”‚   β”œβ”€β”€ react
β”‚   β”‚   β”‚   β”œβ”€β”€ App.tsx     # react application
β”‚   β”‚   β”‚   └── index.tsx   # client-side entry point
β”‚   β”‚   └── index.ts        # server-side entry point
β”‚   β”œβ”€β”€ bun.lock.b
β”‚   β”œβ”€β”€ package.json
β”‚   β”œβ”€β”€ tsconfig.json
β”‚   └── yarn.lock

src/index.ts

// src/index.ts
import { Elysia } from "elysia";
import { staticPlugin } from '@elysiajs/static'
import { renderToReadableStream } from 'react-dom/server'
import { createElement } from "react";
import App from './react/App'

// bundle client side react-code
await Bun.build({
  entrypoints: ['./src/react/index.tsx'],
  outdir: './public',
});

// start a new Elysia server on port 3000
const app = new Elysia()
  .use(staticPlugin())
  .get('/', async () => {

    // create our react App component
    const app = createElement(App)

    // render the app component to a readable stream
    const stream = await renderToReadableStream(app, {
      bootstrapScripts: ['/public/index.js']
    })

    // output the stream as the response
    return new Response(stream, {
      headers: { 'Content-Type': 'text/html' }
    })
  })
  .listen(3000)

src/react/App.tsx

// src/react/App.tsx
import React, { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <html>
      <head>
        <meta charSet="utf-8" />
        <title>Bun, Elysia & React</title>
        <meta name="description" content="Bun, Elysia & React" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body>
        <h1>Counter {count}</h1>
        <button onClick={() => setCount(count + 1)}>Increment</button>
      </body>
    </html>
  );
}

src/react/index.tsx

/// <reference lib="dom" />
/// <reference lib="dom.iterable" />

import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App.js'

hydrateRoot(document, <App />)

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2021",
    "jsx": "react",
    "jsxFactory": "React.createElement",
    "jsxFragmentFactory": "React.Fragment",
    "module": "ES2022",
    "moduleResolution": "node",
    "types": ["bun-types"],
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

package.json

{
  "name": "bun-elysia-react-ssr",
  "version": "1.0.0",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "bun run --watch src/index.ts"
  },
  "dependencies": {
    "@elysiajs/static": "^0.6.0",
    "elysia": "latest",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.21",
    "@types/react-dom": "^18.2.7",
    "bun-types": "latest"
  },
  "module": "src/index.js"
}

Article and source code by Colin Teahan

πŸ₯³