DEV Community

ndesmic
ndesmic

Posted on • Edited on • Originally published at indepth.dev

Building a react static site generator in ~20 lines of code, 4 dependencies and no transpilers

Fooling around with some static site generators I realized that most are complete overkill for what I was trying to do. For a static site I really don't need all that fancy HMR spinning my fans nor is above-the-fold CSS inlining really necessary. And yet for slightly complex tasks like using a custom SASS plugin for a design system it became difficult especially since node-sass isn't exactly compatible with dart-sass systems like NextJS uses (a rant for another time). So I decided to build my own simple one, and it's not hard at all. Instead of an afternoon reading through Gatsby documentation, I got my own working in the same amount of time with just 20 lines of code.

What is Static Site Generation?

If you are unfamiliar with the term Static Site Generator (SSG for short) it's basically a fancy way to say we template pages at build time. HTML doesn't give us great ways to reuse content so we need an abstraction that lets us build pages with similar layouts and components. You can do this at three levels, client-side, server-side or at build.

Client-side rendering (CSR) is how a typical React/Vue/framework-of-your-choice app works, javascript is loaded and then it generates a bunch of DOM elements based on the createElement calls. This is of course leaves us with a gap trying to do the initial render with a blank page and won't work if the user has Javascript disabled (which can happen for certain web crawlers).

Server-side rendering (SSR) is fancier but for React requires a node backend which can be limiting. It essentially renders all the components on the server and then sends it off to the client. The page will be mostly rendered which is good but for more complex cases the framework will have to go through and "hydrate" it which is basically checking everything matches what it expects and it typically does this before anything is interactive.

What you ideally want is build-time rendering with minimal hydration. This means the html is just an html file and the server does nothing (fast!). We can statically serve the site which comes with nice benefits. Unlike JS-centric frameworks, we don't need the server to be written in node, anything that serves static files will work. This also lets us do things like serve the site from a CDN which further improves latency. Hydration can still be a problem though.

So why do this with React at all? Well it mostly comes down to using existing tooling and component toolkits. If you have a bunch of existing React components, it's probably not worth rebuilding it all to get the benefits of static markup. Though you might find using other templating engines easier if you are starting from scratch or doing something simple.

ESM first

If you've read my other blogs I'm very big at getting the JS ecosystem out of the CommonJS rut. Unfortunately NextJS and Gatsby two of the most popular React SSG solutions both require CJS; I don't want to write it, and I certainly don't want to maintain an elaborate build system, but I suspect I'll be waiting a long time for them to modernize. So to start, in the package.json, we'll add a line for type: "module" to start using ESM. We're already ahead of the game with no extra build dependencies.

Renderers

Static site generation is just a bunch of renderers that take one type of content and convert it to another, in our case we want to take JSX and convert it to HTML but we also might want to turn SASS into CSS or optimize images build a pre-cache layer with workbox etc. We can split these into individual renderers. Some SSGs support multiple types of templating out of the box like Eleventy (although Eleventy as of this writing doesn't support JSX, but we will!), others like Gatsby have plugin systems to handle different types of content. Ours can do the same thing but to keep it simple I'm just building the JSX to HTML renderer, the others are trivial as most tools have a CLI program you can run that does this.

JSX?

React uses JSX and unfortunately that's a lot of complexity. Nobody really wants to deal with webpack and babel for just that. The alternative is to use React.createElement calls directly but for even mildly complex HTML this gets unreadable fast. Aliasing gets you a little further. Luckily, there's something that requires no transpilation:

4mme80

htm is a very cool library by Jason Miller (who makes lots of cool libraries). It's JSX except instead it uses tagged template literals. This means we can have the JSX experience without any transpilers. It's also very small and for our purposes very fast. So instead of JSX files we'll have JS files and they'll just use htm instead saving us tons of build complexity.

The Code

https://github.com/ndesmic/react-ssg/tree/v0.1

/
  renderers/
    htm-react-renderer.js
    htm-preact-renderer.js
  templates/
    react/
      _layout.react.js
      index.react.js
    preact/
      _layout.preact.js
      index.preact.js
  utilities/
    utils.js
Enter fullscreen mode Exit fullscreen mode
//renderers/htm-react-renderer.js
import { promises as fs } from "fs";
import ReactDOM from "react-dom/cjs/react-dom-server.node.production.min.js";
import { fileURLToPath, pathToFileURL } from "url";
import yargs from "yargs";

import { ensure } from "../utilities/utils.js";

const args = yargs(process.argv.slice(2)).argv;
const templatesUrl = pathToFileURL(`${process.cwd()}/${args.t ?? "./templates/"}`);
const outputUrl = pathToFileURL(`${process.cwd()}/${args.o ?? "./output/"}`);

const files = await fs.readdir(fileURLToPath(templatesUrl));
await ensure(fileURLToPath(outputUrl));

for (const file of files){
    if (/^_/.test(file)) continue;
    const outfile = new URL(file.replace(/\.js$/, ".html"), outputUrl);
    const path = new URL(file, templatesUrl);
    const { title: pageTitle, body: pageBody, layout: pageLayout } = await import(path);
    const body = typeof (pageBody) === "function" ? await pageBody() : pageBody;
    const { layout } = await import(new URL(pageLayout ?? "_layout.js", templatesUrl));
    const output = ReactDOM.renderToString(layout({ title: pageTitle, body }));
    await fs.writeFile(fileURLToPath(outfile), output);
}
Enter fullscreen mode Exit fullscreen mode

We need 4 outside packages htm, react, react-dom, and yargs.

yargs is completely optional. You may substitute your own argument parsing, or do entirely without either with hardcoding, environment variables or by loading a configuration file. I use yargs incase the user wants to customize the output folder or the template folder via CLI, otherwise they are output and templates respectively. It also leaves room for future enhancement.

We iterate through the files in the templates folder, ignoring ones that begin with _ (these will indicate partials like layouts). We use ReactDOM.renderToString to render the page into HTML strings and write it out to disk. Instead of rebuilding all of the boilerplate markup per page, we use another file for layout. In the layout we simply slot the page properties in where we want them. Also, note that there's a check to see if body is a function and if so we await the result. This is completely optional but is a nice quality of life enhancement so you can use static markup, dynamic markup from props (the example does not have this capability but it could) or asynchronously render, allowing you to do things like fetch data or crawl the file system before rendering. After the template is rendered out, it goes to the output folder with the same name as the input file, just replacing .js with .html.

As for what layout and the page looks like:

//templates/react/home.react.js
import { html } from "htm/react/index.mjs";

export const title = "Home React";
export const layout = "_layout.react.js"

const Header = ({ text }) => html`<h1>${text}</h1>`

export const body = html`
    <div>
        <${Header} text="Hello World!"><//>
        <p>A simple SSG Site with React</p>
    </div>
`;
Enter fullscreen mode Exit fullscreen mode

Pages can have all sorts of metadata aside from the actual markup and here I've demonstrated some useful ones. body will be the page's main JSX representation but I also added title, which is templated into the title tag and layout which is the path to the layout.

htm comes with some handy shortcuts for using React and Preact, we just choose the right one by importing it. If you wanted to use a JSX compatible library that's not React or Preact you need to manually bind to the h function (we'll do React manually for illustration):

import htm from "htm";
import React from "react";
const html = htm.bind(React.createElement);

const myElement = html`<div></div>`;
Enter fullscreen mode Exit fullscreen mode

htm is also nice enough to have multiple module formats. Use the .mjs version for ESM modules, while the .js CJS version happens to work we want to use the real thing.

If we want to use a React component with htm we need to use expressions to template in the React component, e.g. <${ReactComponent} /> where the templated value is a react component function/constructor. Since it would be ergonomically awkward to do that again for the closing tag, htm lets us omit the tag name for closing tags, the convention is to use <//> to close (though my understanding is that this is simply convention and closing tag name values are actually ignored). As a general rule of thumb, anywhere you'd use curly braces { ... } in JSX you'll use expression tags ${ ... } in htm.

//templates/react/_layout.react.js
import { html } from "htm/react/index.mjs";

export const layout = data => html`
<html>
    <head>
        <title>${data.title}</title>
    </head>
    <body>
        ${data.body}
    </body>
</html>
`;
Enter fullscreen mode Exit fullscreen mode

Layout is similar. It has the basic HTML boilerplate but it can slot different page information into parts outside of the main content area.

Lastly here's the ensure function:

//utilities/utils.js
import { join } from "path";
import { promises as fs } from "fs";

export const exists = path =>
    fs.access(path).then(() => true).catch(() => false);

export async function ensure(path) {
    const pathSplit = path.split(/[/\\]/); //windows and *nix style paths
    let currentPath = pathSplit[0];
    for await (let part of pathSplit.slice(1, pathSplit.length - 1)) {
        if(!part.trim()) continue;
        currentPath = join(currentPath, part);
        if (!await exists(currentPath)) {
            await fs.mkdir(currentPath);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It just makes sure the nested directories exist. To keep the post title honest (since this is more than 20 lines), you can make the 4th dependency mkdirp and omit parameter parsing, otherwise it's 3 dependencies and another ~10 lines. I don't like to take dependencies when I can copy-paste from my personal stash of snippets.

Running it

node renderers/htm-react-renderer.js will take all files in templates and emit them as HTML pages. You can use options like node renderers/htm-react-renderer.js -o ./output/react/ to change the name of the output folder or node renderers/htm-react-renderer.js -t ./templates/react/ to change the name of the template folder. This is how the example builds a React and Preact version in the npm scripts.

Preact

If you want to go even smaller and even simpler we can use Preact instead (my node_modules folder was ~2.68MB using just preact!). In the example code I added the preact renderer side-by-side to test it out and to show how you could make another renderer. In your own you might choose just one or the other.

//renderers/htm-preact-renderer.js
import { promises as fs } from "fs";
import { fileURLToPath, pathToFileURL } from "url";
import yargs from "yargs";
import render from "preact-render-to-string";

import { ensure } from "../utilities/utils.js";

const args = yargs(process.argv.slice(2)).argv;
const templatesUrl = pathToFileURL(`${process.cwd()}/${args.t ?? "./templates/"}`);
const outputUrl = pathToFileURL(`${process.cwd()}/${args.o ?? "./output/"}`);

const files = await fs.readdir(fileURLToPath(templatesUrl));
await ensure(fileURLToPath(outputUrl));

for (const file of files) {
    if (/^_/.test(file)) continue;
    const outfile = new URL(file.replace(/\.js$/, ".html"), outputUrl);
    const path = new URL(file, templatesUrl);
    const { title: pageTitle, body: pageBody, layout: pageLayout } = await import(path);
    const body = typeof (pageBody) === "function" ? await pageBody() : pageBody;
    const { layout } = await import(new URL(pageLayout ?? "_layout.js", templatesUrl));
    const output = render(layout({ title: pageTitle, body }));
    await fs.writeFile(fileURLToPath(outfile), output);
}
Enter fullscreen mode Exit fullscreen mode

Everything is the exact same but we can toss react-dom and ReactDom.renderToString and use preact-render-to-string's render instead.

The pages are the same except we use htm's Preact export.

//templates/preact/home.preact.js
import { html } from "htm/preact/index.mjs";

export const title = "Home!";

export const page = html`
    <div>
        <h1>Hello World!</h1>
        <p>A simple SSG Site</p>
    </div>
`;
Enter fullscreen mode Exit fullscreen mode

_layout.preact.js is the same thing so I'm not going to bother showing it.

Benefits

Some nice benefits I've noticed with this approach over existing frameworks are the absolutely tiny size, simplicity, native ESM and native error messages.

Where to go from here?

I used a similar template to make a custom SASS build and it's as easy as piping the renderers together node renderers/htm-react-renderer.js && node renderers/sass-renderer.js. That can easily be a package.json script, but if I needed more support I could also create a small node script to pull it all together. You could do this for LESS, other templating languages, whatever you want really.

Another thing I think might be worth looking at is how to make it work with Deno. Everything is so simple, it should be possible to convert it for people who want to use that instead.

Of course this is a very simple case of outputting HTML. There are deeper topics like script bundling and progressive hydration that framework authors pour lots of time into and where this may not be the most efficient path to take. But hopefully this shows how simple SSG with React can be.

You can find the code at https://github.com/ndesmic/react-ssg/tree/v0.1 .

Top comments (0)