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
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.
/// <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.
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',
});
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:
- How to server static files with Elysia
- How to create a simple website with Elysia
- How to configure Bun with DOM types
- How to configure Bun with JSX documentation
- How to bundle assets with Bun documentation
- How to render react on the server
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