Building static websites with JS bundling

Table of contents

  1. Esbuild + Zola
  2. Build pipeline
  3. Serving for development
  4. Running the scripts

This is probably the third or fourth time I am redoing my personal website in the last two years. The main reason is probably because it’s just fun to learn something new, and building something new is a good opportunity to play with new technologies. Another reason is that I’ve never been truly happy with what I’ve used at that time.

On one hand, I don’t see why I should run excessive amount of Javascript code on visitors’ browsers just to render and fetch some static content. So frameworks like Nuxt, Gatsby, or others that behave more like single page apps with pre-rendering feel excessive compared to my needs. I much prefer the simplicity of generators that just take a bunch of Markdown files to create HTML files.

On the other hand, I’ve always felt that the standard way to manage Javascript files in generators like Hugo or Zola is a bit clunky. You cannot use package management or typescript, and bundle it all in a neat .js file. Thus I needed a way to bundle Javascript file, while keeping the simplicity of those static site generators.

Esbuild + Zola

I decided to use Esbuild and Zola for remaking my personal website. For the website generator itself, I used Hugo in the past, but I really dislike its template engine. Something shared by Zola’s creator, and made them create Tera — a template engine written in Rust inspired by Jinja2 and Django. I haven’t done an in-depth performance comparison, but they are both reasonably fast for my needs.

Because this website is not using Javascript extensively, I didn’t need a bundle with a lot of bells and whistles. Speed was a much more important decision factor to me. Thus I settled on Esbuild. At the time of writting, it bundles all the Javascript code in a few milliseconds on my computer, and Zola takes about 70ms.

I’ll use those two tools in this article, but you can achieve the same things with other bundlers and generators.

Build pipeline

The build pipeline is probably the most straightforward part here: build the Javascript bundle, store it into the static/ folder, then run the website generator. I’ve opted to make a small JS script for portability doing just that.

const { spawn } = require('child_process');
const esbuild = require('esbuild');

async function build() {
  // Build Javascript bundle
  await esbuild.build({
    entryPoints: ['src/index.js'],
    outfile: 'static/js/main.js',
    minify: true,
    bundle: true,
    logLevel: 'info',
  });

  // Build the website
  spawn('zola', [
    'build'
  ], {
    stdio: 'inherit'
  });
}

build();

Serving for development

A lot of tools provide the ability to run a webserver for development purposes. This is the case for both esbuild and Zola. However, I don’t want to run two webservers, with one for Javascript and one for HTML. I just need a single webserver, and automatically rebuild whenever a file has changed.

Here, I also wanted to leverage optimisations specific to the generator I’m using. Zola doesn’t support incremental builds, except when using its internal webserver. In its current state, with just a handful of pages, it takes 70ms to run zola build, versus 16ms to re-generate a page when using zola serve.

Thus I’ve opted to use Zola’s serving capabilities, and run esbuild whenever a Javascript file has changed.

For the Javascript files, I’m using chokidar to listen on changes and run esbuild as needed. Zola will then automatically detect the new bundle. That part is pretty fast as esbuild takes single-digit milliseconds, while Zola doesn’t need to do any render — just copy the file into the public/ folder.

const { spawn } = require('child_process');
const chokidar = require('chokidar');
const esbuild = require('esbuild');

async function buildJS() {
  await esbuild.build({
    entryPoints: ['src/index.js'],
    outfile: 'static/js/main.js',
    minify: true,
    bundle: true,
    logLevel: 'info',
  });
}

async function serve() {
  // Initial JS build
  await buildJS();

  // Start the server
  // Zola will automatically listen to changes
  const zola = spawn('zola', [
    'serve',
    '--drafts',
    '--port',
    '3000',
  ], {
    stdio: 'inherit'
  });

  // Stop this if zola closes
  zola.on('close', () => {
    process.exit();
  });

  // Listen for javascript file changes
  chokidar.watch('src/**/*', {
    ignoreInitial: true,
  })
    .on('add', buildJS)
    .on('change', buildJS)
    .on('unlink', buildJS);
}

serve();

Running the scripts

From there, I referenced both scripts in the package.json file at the root of my project. This way, I can just run npm run serve or npm run build to serve and build this website respectively.

{
  // ...
  "scripts": {
    "serve": "node scripts/serve.js",
    "build": "node scripts/build.js",
    "clean": "rm -rf public dist static/js"
  },
  // ...
}

If you want to look for yourself, you can find the entire source for this website in the GitHub repository.