Written by Clara Ekekenta✏️
In this tutorial, we’ll use the Bun Bundler to create a fast, Next.js-like blog application with server-side rendering (SSR) and client-side hydration. We’ll also explore Bun’s new JavaScript Macros feature, which is part of the tighter integration that Bun aims for between its bundler and runtime to boost speed.
Let’s get started!
Jump ahead:
- Prerequisites
- What is Bun?
- What is Bun Bundler?
- Manually bundling a project with Bun
- Automatically bundling a project with Bun
Prerequisites
To follow along with this tutorial, you’ll need the following:
- Node.js v14 or later installed on your machine
- npm; this is usually bundled with Node.js
- CURL; you can install it with the following command:
sudo apt install curl
- A basic understanding of Typescript, React, and web development principles will be beneficial but is not required
What is Bun?
Bun is a sophisticated JavaScript runtime that is equipped with inbuilt Web APIs, including Fetch and WebSockets, among many others. It incorporates JavaScriptCore, an engine renowned for its speed and memory efficiency, even though it's typically more challenging to embed compared to popular engines like V8.
Bun is designed to expedite the JavaScript development process to unprecedented speeds. As an all-inclusive tool, Bun doesn't just enhance compilation and parsing rates, it also comes with its own suite of tools for dependency management and bundling. This makes Bun a comprehensive, one-stop solution for developers looking to optimize their workflow and improve efficiency.
What is Bun Bundler?
Bun Bundler is a fast native bundler that is part of the Bun ecosystem. It is designed to reduce the complexity of JavaScript by providing a unified plugin API that works with both the bundler and the runtime. This means any plugin that extends Bun's bundling capabilities can also be used to extend Bun's runtime capabilities.
Bun Bundler is designed to be fast, with benchmarks showing it to be significantly faster than other popular bundlers. It also provides a great developer experience, with an API designed to be unambiguous and unsurprising.
Bun Bundler supports a variety of file types and module systems, and it has inbuilt support for tree shaking, source maps, and minification. It also has experimental support for React Server Components.
Manually bundling a project with Bun
To get started with this tutorial, let’s walk through the process of setting up a project and manually bundling and running it with Bun.
Setting up the environment
First, we’ll need to install Bun on our Linux machine. Let’s run the following command on the terminal:
curl -fsSL https://bun.sh/install | bash
Once installation is complete, we’ll run the following commands to add Bun to $PATH
and confirm the build:
exec /bin/zsh
bun --help
Setting up the Node.js project
Next, we’ll need to set up our project environment. Let’s start by creating a new directory for our project and navigate into it:
mkdir bun-blog && cd bun-blog
Then, we’ll initialize a new Node.js project:
npm init -y
Creating the application files
For this tutorial, we'll start by creating a simple client-side rendered React app. We’ll create two files, index.tsx
and Blog.tsx
, and then add the following code to the Blog.tsx
file:
export function Blog(props: {title: string, content: string}) {
return (
<div>
<h2>{props.title}</h2>
<p>{props.content}</p>
</div>
);
}
Now, let’s import the the Blog
function in the index.tsx
file:
import * as ReactDOM from 'react-dom/client';
import React from 'react';
import { Blog } from './Blog.tsx';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( <Blog title="My First Blog Post" content="This is the content of my first blog post." />)
Bundling the application
Next, we’ll bundle our application using the following command:
bun build ./index.tsx --outdir ./out
The bun build
command tells Bun to generate a new bundle from the index.tsx
file and write it to the ./out
directory. The bundled file will be ./out/index.js
.
Let’s create an index.html
file in the ./out
directory to run the file with the code snippet below:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.js"></script>
</body>
</html>
Running the application
To run our application, we’ll need to serve the ./out
directory. We can do this by using the bunx serve
command, like so:
bunx serve out
To see the bundled app in action, visit http://localhost:5000
.
Automatically bundling a project with Bun
Instead of manually creating the application files and bundling them as we did previously, we can leverage the bun create
react-ssr
command, which has been updated to use Bun.build
under the hood. This will enable us to easily scaffold a new SSR project.
Creating a new Bun project
To start, we’ll need to create a new Bun React server-side rendered project with the command below:
bun create react-ssr
Next, let’s navigate to the project folder and run the application:
cd react-ssr
bun install
bun run dev
After running the above command, the Bun application will run on http://localhost:3000
: Let’s take a look at the important files in this newly created project:
-
dev.tsx
: This file is instrumental in the development process. It constructs a browser version of all pages viaBun.build
. When the development server is active, it responds to incoming requests by rendering the corresponding page from thepages
directory into static HTML. This HTML output includes a<script>
tag that sources a bundled version of thehydrate.tsx
file -
hydrate.tsx
: The primary role of this file is to reinvigorate the static HTML sent back by the server, ensuring a smooth and dynamic user experience on the frontend -
pages/*.tsx
: This directory comprises various pages that align with Next.js routing conventions; the system routes incoming requests based on the defined pages in this directory
Understanding SSR and hydration
When we talk about modern web applications, two terms that frequently come up are server-side rendering and hydration. Let’s take a closer look to gain a better understanding:
- SSR: In traditional web applications, rendering often takes place on the client side, but with SSR the server plays a more proactive role. When a user makes a request, the server pre-renders the page into HTML and sends this static HTML as a response. This results in faster initial page load times and better SEO performance. In our project, the
dev.tsx
file handles this process, ensuring that the appropriate page in thepages
directory is converted to static HTML for incoming requests - Hydration: While SSR provides the initial speed, we don't want our app to remain static; we want it to be interactive. This is where hydration comes in. After SSR sends the static HTML page to the browser, the associated JavaScript (in our case, the
hydrate.tsx
file) runs to “hydrate" this static page, attaching event listeners and making it fully interactive. This creates a seamless transition from a static page to a dynamic app without reloading the browser
Together, SSR and hydration give us the best of both worlds — a fast initial load time with a rich, dynamic user experience.
Creating pages
Bun adopts a file system-based routing approach, similar to Next.js. Here we’ll update the react-ssr/pages/index.tsx
file to fetch some blogs from the JSONPlaceholder API. Then we’ll create another page to handle the creation of new blog posts.
Let’s start by updating the react-ssr/pages/index.tsx
file with the following code:
import { useEffect, useState } from "react";
import { Layout } from "../Layout";
import { IBlog } from "../interface";
export default function () {
const [posts, setPosts] = useState([]);
useEffect(() => {
async function getPosts() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
const posts = await res.json();
console.log(posts);
setPosts(posts);
}
getPosts();
}, []);
return (
<Layout title="Home">
<div className="posts-container">
<a href="/posts">Create New Post</a>
{posts?.map((post: IBlog) => (
<article key={post.id} className="post-article">
<h2 className="post-title">{post.title}</h2>
<p className="post-content">{post.body}</p>
</article>
))}
</div>
</Layout>
);
}
Here, useEffect
fetches posts from the API whenever the Home
component is mounted. The posts are stored in the component's local state and are displayed on the screen. This ensures that the displayed data is fresh every time the component is rendered, making it suitable for data that updates frequently.
Now, let’s create an IBlog
interface in the react-ssr/interface
folder and add the following code:
export interface IBlog {
id: string;
title: string;
body: string;
}
Creating a post page
To allow authors to create new posts, we’ll need to build a post page. Let’s create a posts/index.tsx
file in the react-ssr/pages/
folder, like so:
import { Layout } from "../../Layout";
export default function () {
return <Layout title="Create a new Post"></Layout>;
}
Adding interactivity
To bring our blog to life, we’ll need to add some interactive elements. We'll start with a simple form on our posts/index.tsx
page to gather information for new posts. Since we do not have a backend for this application, we’ll store the posts in the JSONPlaceholder API:
import { useState } from "react";
import { Layout } from "../../Layout";
export default function () {
const [title, setTitle] = useState("");
const [body, setContent] = useState("");
const handleSubmit = (e: { preventDefault: () => void }) => {
e.preventDefault();
// we'll handle the submission logic here later
};
return (
<Layout title="Create a new Post">
<div>
<form onSubmit={handleSubmit}>
<label>
Title:
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<label>
Content:
<textarea
value={body}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<button type="submit">Submit</button>
</form>
</div>
</Layout>
);
}
The above code defines a React functional component named CreatePost
. It uses the useState
Hook to manage local state for the title
and content
of a new post, initially setting both to an empty string. It also sets up a handleSubmit
function to be used when the form is submitted, although we have not yet implemented the form submission.
The component's render function returns a form with two input fields, for the title and content, and a submit button. The state of the title
and content
is linked to their respective input fields, with their state being updated whenever the input field value changes.
Next, let’s update the handleSubmit
function in the posts/index.tsx
file to store the new post:
...
import { IBlog } from 'interface/IBlog';
...
const handleSubmit = async (e: { preventDefault: () => void; }) => {
e.preventDefault();
const id = Math.random().toString(36).substr(2, 9);
const newPost: IBlog = { id, title, body };
// Send a POST request to the JSONPlaceholder API
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify(newPost),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
});
const data = await res.json();
console.log(data);
setTitle("");
setContent("");
};
...
The handleSubmit
function that we mentioned previously is an event handler for form submissions. It first prevents the default form event; then it generates a unique id
and creates a new post with the title
and body
from the state.
The new post is then sent to the JSONPlaceholder API via a POST
request. The response from the server is logged to the console, and the form is reset by clearing the title and content states.
Adding styling
Now we’ll add some styling to our blog application to make it visually appealing. Let’s update the public/index.css
file to style all the components in the application, like so:
.posts-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 2rem auto;
padding: 1rem;
}
.post-article {
width: 80%;
margin-bottom: 2rem;
border: 1px solid #ddd;
border-radius: 10px;
padding: 1rem;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
}
.post-title {
font-size: 2rem;
color: #333;
margin-bottom: 1rem;
}
.post-content {
font-size: 1rem;
color: #666;
}
Using Bun Macros
Bun Macros is a powerful feature in Bun that allows us to replace parts of our code with other code at build time. This can be useful for a variety of scenarios, such as optimizing performance, removing code that isn't needed in a particular build, or improving code readability.
In our blog application, we‘ll use Bun Macros to replace the API URL with a local storage key when we're running tests. Not having to make actual API calls during testing will make our tests faster and more reliable.
First, we’ll create a macro.ts
file and then use the createMacro
function from bun.macro
to create the macro. In this tutorial, we'll create a macro that replaces the API URL with a local storage key:
export const apiUrl = () => {
if (process.env.NODE_ENV === 'test') {
return 'localStorageKey';
} else {
return 'https://jsonplaceholder.typicode.com/posts';
}
};
Now, we can use this macro in our handleSubmit
function:
...
import {apiURL} from './macro.ts' with { type: 'macro' }
...
const handleSubmit = async (e: { preventDefault: () => void; }) => {
e.preventDefault();
const id = Math.random().toString(36).substr(2, 9);
const newPost: IBlog = { id, title, body };
// Use the apiUrl macro
const res = await fetch(apiUrl(), {
method: 'POST',
body: JSON.stringify(newPost),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
});
const data = await res.json();
setTitle("");
setContent("");
};
...
With this, our blog application is complete! We've used Bun Bundler to create a fast, Next.js-like blog application with server-side rendering and client-side hydration.
Conclusion
In this tutorial, we demonstrated how to set up a project using Bun, create a simple application, bundle it using Bun Bundler, and serve it using Bun's inbuilt server. We also showed how to simplify the process of setting up a new project using the bun create
command, which uses Bun.build
under the hood.
Bun Bundler is a powerful tool that can help reduce the complexity of your JavaScript projects and improve your development speed. Its integration with the Bun runtime and its support for a wide range of file types and module systems make it a versatile tool for any JavaScript developer. Whether you're building a simple client-side app or a complex full-stack application, Bun Bundler has the features and performance to meet your needs.
Are you adding new JS libraries to improve performance or build new features? What if they’re doing the opposite?
There’s no doubt that frontends are getting more complex. As you add new JavaScript libraries and other dependencies to your app, you’ll need more visibility to ensure your users don’t run into unknown issues.
LogRocket is a frontend application monitoring solution that lets you replay JavaScript errors as if they happened in your own browser so you can react to bugs more effectively.
LogRocket works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Build confidently — Start monitoring for free.
Top comments (0)