Build a fast, Next.js-like app with Bun

mangelosanto

Matt Angelosanto

Posted on August 23, 2023

Build a fast, Next.js-like app with Bun

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

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
Enter fullscreen mode Exit fullscreen mode

Installing Bun Linux Machine Once installation is complete, we’ll run the following commands to add Bun to $PATH and confirm the build:

exec /bin/zsh 
bun --help 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then, we’ll initialize a new Node.js project:

npm init -y
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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." />)
Enter fullscreen mode Exit fullscreen mode

Bundling the application

Next, we’ll bundle our application using the following command:

bun build ./index.tsx --outdir ./out
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next, let’s navigate to the project folder and run the application:

cd react-ssr
bun install
bun run dev
Enter fullscreen mode Exit fullscreen mode

After running the above command, the Bun application will run on http://localhost:3000: Bun Application Running Local Host 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 via Bun.build. When the development server is active, it responds to incoming requests by rendering the corresponding page from the pages directory into static HTML. This HTML output includes a <script> tag that sources a bundled version of the hydrate.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 the pages 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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("");
  };
...
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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';
  }
};
Enter fullscreen mode Exit fullscreen mode

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("");
  };
...
Enter fullscreen mode Exit fullscreen mode

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 Signup

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.

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on August 23, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related