How This Site Was Made

March 09, 2025

By: Mitch

Introduction

Ever wanted to build a website without relying on complex frameworks? In this blog, I'll walk you through how I built this site with Bun — blending static content and dynamic routes to serve this very blog post!

Bun as a static server

Bun is a modern JavaScript runtime designed for speed. It's an alternative to Node.js that aims to simplify development with built-in features like a fast HTTP server, native file system utilities, and first-class TypeScript support. At the most basic level, this site is built mostly on top of Bun's HTTP server. Recently, as of this blog post, Bun released an awesome update to this server which added support for static imports of HTML files in order to be bundled and served by Bun automatically.

Static content with Bun

When it comes to this site, most pages are handled by Bun's static imports. This feature simplifies serving static content by letting you import .html files directly into your server code, reducing boilerplate. It is pretty simple to set this up:

import { serve } from "bun";
import html from "./my-html-page.html";

serve({
  port: 3000,
  routes: {
    "/*": html,
  },
});

What is particularly cool about this feature is that it will also automatically transpile and bundle React so you can serve a single-page app all from one route on your server! In my case though, I didn't want an SPA. I wanted static content that I could serve without needing to render on the client side. To handle this, I could manually write all of the HTML boilerplate and markup myself, which would be pretty tedious the more pages I add, or I could still use React.

Handling more than one page

Since I wanted to keep the site simple and modular, I decided to stick with React. React's component model makes it easy to build reusable and composable components that I can use across different pages. However, this posed a new challenge: how do I render React components as static HTML without running any JavaScript on the client side?

To solve this, I built a custom static content generation solution. Given the relatively simple nature of this site, I didn't want to rely on third-party libraries or complex tools. The core of this solution involves rendering React components into static HTML using renderToStaticMarkup. Once the React components are rendered as strings, I just insert them into a basic HTML template. From there, I write the resulting HTML to a file that Bun can serve. This allows me to create multiple static pages, each with its own content, and easily expand the site by adding more pages.

// build.tsx
import { renderToStaticMarkup } from "react-dom/server";

function addContentToHTML(content: string) {
    const html = `
<!DOCTYPE html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>My Static Site</title>
</head>
<body>
    ${content}
</body>
</html>
`;
    return html;
}

const markup = {
    home: <div>Hello world!</div>,
    page2: <div>This is another page!</div>
}

for (const [name, jsx] of Object.entries(markup)) {
    // render our JSX into HTML
    const content = renderToStaticMarkup(jsx);
    // write that HTML to a file
    await Bun.write(`./dist/${name}.html`, addContentToHTML(content));
}

Once the build script runs and generates the HTML files, we can import and serve them with Bun.

import { serve } from "bun";
import home from "./dist/home.html";
import page2 from "./dist/page2.html";

serve({
  port: 3000,
  routes: {
    "/": home,
    "page2": page2,
  },
});

And with that, you have a statically generated multi-page website built with React and Bun!

Blog posts using dynamic routes

As the site grows and more pages are added, maintaining static routes becomes more tedious. For example, if I want to add a blog, I don't want to manually create a new route in my Bun server for every post. To fix this, I decided to use dynamic routing for blog posts. Additionally, I wanted to write my posts using MDX, which lets me focus on the content without worrying about the underlying HTML or JSX structure.

To integrate MDX-based blog posts into the site, I read the MDX files and compile them into React components that can be rendered into static HTML. The MDX content is compiled using @mdx-js/mdx into a function, which is then executed to produce the corresponding React component. This allows me to render the MDX content inside my static HTML structure.

Here’s how it works:

// build.tsx
import { MDXProvider } from "@mdx-js/react";
import { compile } from "@mdx-js/mdx";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { renderToStaticMarkup } from "react-dom/server";

// This manifest is generated automatically and imported but shown here for clarity
const blogManifest = {
    "my-blog": "path-to-my-blog.mdx",
}

for (const [name, path] of Object.entries(blogManifest)) {
    // Read the MDX file content
    const mdxContent = await Bun.file(path).text();
    // Compile the MDX content into a function
    const compiled = await compile(mdxContent, { outputFormat: "function-body" });
    // Create a new function to run the compiled code and export the React component
    const { default: Blog } = new Function("runtime", String(compiled))({
        Fragment,
        jsx,
        jsxs,
    });

    const content = renderToStaticMarkup(
        <MDXProvider>
            <Blog />
        </MDXProvider>,
    );
    await Bun.write(`./dist/blogs/${name}.html`, addContentToHTML(content));
}

With the MDX content compiled into static HTML files, the next step is to handle dynamic routes for each blog post. Instead of manually adding a new route for every blog, we use dynamic routing in Bun. This allows us to define a single route that can serve any blog post by its name, based on the :name parameter in the route path.

Here’s how the dynamic routing works:

import { serve } from "bun";
import home from "./dist/home.html";
import page2 from "./dist/page2.html";

serve({
  port: 3000,
  routes: {
    "/": home,
    "page2": page2,
    "/blog/:name": async (req) => {
        // Bun provides typesafe named params from the route path in the `req` object
        const { name } = req.params;
        const blog = Bun.file(`./dist/blogs/${name}.html`);

        // Make sure it exists before attempting to serve it
        if (await blog.exists()) {
            return new Response(blog);
        }
        return new Response("Not found", { status: 404 });
    }
  },
});

And there you have it! Dynamic routes with statically generated content. While this is the bare minimum needed to get the site up and running, adding styles and improving the design will require additional steps. I've provided the foundation for that in this blog post so I'll leave that up to you to figure out as a challenge for yourself!

Benefits and drawbacks to this approach

As with any software solution, there are benefits and trade-offs to consider. For this site, the goal was to keep things simple by avoiding the use of an existing meta-framework, which comes with both advantages and challenges.

Benefits of a custom solution

Building my own static content generator offers several advantages:

  • Fewer dependencies to manage
  • Minimal boilerplate and a simple setup
  • Customizability for future changes
  • An opportunity to learn new techniques and approaches

Drawbacks to the custom approach

While building a custom static content generator offers flexibility, there are some drawbacks compared to using a meta-framework:

  • Limited hot-module reloading: Bun supports hot reloading for static pages, but issues arise with rerendering and reloading at times.
  • Lack of built-in features: Bun does not automatically handle bundling, minifying, or serving markup and CSS for dynamic pages (it does for the static imports though), requiring manual setup.
  • Custom deployment setup: Unlike some frameworks that offer pre-configured deployment solutions, I had to set up my own deployment process for the site.

Conclusion

In this blog we've explored how to create a simple statically generated website using Bun, complete with dynamic routing for blog posts. We've covered the benefits and drawbacks to this approach, like having a simpler setup at the cost of maintenance and out-of-the-box feature support.

I hope I've inspired you to learn and try something like this for yourself! Thanks for giving it a read.

And don’t forget to check out Mitch's Pretty Awesome Computer Things LLC's catalog of awesome software — you might find something useful!