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. The Bun bundler, which is still in Beta as of this writing, was designed for speed and developer experience. It’s an interesting alternative to popular bundlers like webpack, esbuild, and Vite.
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. Bun Macros enables JavaScript functions to be run at the time of bundling.
Let’s get started!
Jump ahead:
To follow along with this tutorial, you’ll need the following:
sudo apt install curl
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.
The 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.
The 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.
The 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.
To demonstrate how to use the Bun bundler and Bun Macros, we’ll build a Next.js-like blog application with server-side rendering and client-side hydration, bundle it with the bundler, and serve it with Bun’s inbuilt server.
To start, let’s use the React SSR library to scaffold a new SSR project:
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 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
filehydrate.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 frontendpages/*.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 directoryWhen 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:
dev.tsx
file handles this process, ensuring that the appropriate page in the pages
directory is converted to static HTML for incoming requestshydrate.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 browserTogether, SSR and hydration give us the best of both worlds — a fast initial load time with a rich, dynamic user experience.
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; }
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>; }
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.
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; }
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 the Bun bundler to create a fast, Next.js-like blog application with server-side rendering and client-side hydration.
In this tutorial, we demonstrated how to set up a Next.js-like project using Bun, create a simple blog application with server-side rendering and client-side hydration, bundle it using the Bun bundler, and serve it using Bun’s inbuilt server. We also incorporated Bun Macros into our project; in our case to improve the speed and reliability of our testing.
The 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, the Bun bundler has the features and performance to meet your needs.
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.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
One Reply to "Build a fast, Next.js-like app with Bun"
Hi Clara! Lovely article, great tutorial! I’m having a couple of issues with some of the code, and was hoping you might be able to help.
VSC doesn’t recognise the “with” syntax at the end of the import {apiUrl} line. It also complains that “An import path can only end with a ‘.ts’ extension when ‘allowImportingTsExtensions’ is enabled.” – which is obviously something I can do myself, but thought I’d mention it in case you wanted to talk about it in the tutorial