Why Most Static Site Generators Miss the Mark
While tinkering with various static site generators, it became clear that the vast majority are far more elaborate than what the task actually requires. For a simple static site, there's no genuine need for the fan-flickering HMR or the inlining of critical CSS above the fold.
The trouble surfaces when more nuanced requirements come into play, such as integrating a custom SASS plugin for a design system. This becomes particularly challenging because node-sass doesn't play nicely with the dart-sass implementations used by frameworks like NextJS—a subject for another discussion entirely.
This led me to build a minimalist alternative. Rather than spending an entire afternoon wading through Gatsby's documentation, I had a working solution up in the same timeframe using a mere 20 lines of code.
Understanding Static Site Generation
For those new to the term, Static Site Generation (SSG) is essentially a method of templating pages at build time. Since raw HTML offers little in the way of reusable content, an abstraction layer is needed to create pages that share common layouts and components. This can happen at any of three stages: client-side, server-side, or during the build process.
Client-side rendering (CSR) is the standard for typical React, Vue, or other framework applications. The JavaScript loads and then generates a series of DOM elements based on createElement calls. The downside is that the initial render starts with a blank page and fails entirely when JavaScript is deactivated—a scenario that some web crawlers encounter.
Server-side rendering (SSR) is more advanced but demands a Node backend when using React, which can be restrictive. In SSR, all components are rendered on the server before being sent to the client. The page arrives mostly complete, but for intricate cases, the framework must "hydrate" it, which involves verifying that everything matches expectations—typically done before any user interaction is possible.
The optimal approach is build-time rendering paired with minimal hydration. This yields a plain HTML file served without any server-side processing—fast and efficient. Serving the site statically brings significant advantages. Unlike JS-centric frameworks, it doesn't require a Node server; any static file server suffices. It also enables CDN serving, which further cuts down on latency. Hydration remains a challenge regardless.
So, why stick with React? It's primarily about leveraging existing tools and component libraries. If you've already got a collection of React components, rebuilding them all just to gain static markup probably isn't worth the effort. Yet, if you're starting fresh or working on something straightforward, you might find simpler templating engines more convenient.
Adopting ESM First
For anyone following my other writings, it's clear I'm passionate about moving the JS ecosystem away from CommonJS. Unfortunately, both NextJS and Gatsby—two of the most popular React SSG solutions—still depend on CJS. I have no interest in writing it and certainly don't want to maintain a complicated build system just to accommodate them. However, I suspect modernization will be a slow process. To kick things off, we'll add type: "module" to the package.json to enable ESM usage. That alone puts us ahead without any extra build dependencies.
Renderers as Building Blocks
Static site generation is essentially a series of renderers that transform one type of content into another. Our goal is to convert JSX into HTML, but we might also want to turn SASS into CSS, optimize images, or create a pre-cache layer with Workbox. These tasks can be split into separate renderers. Some SSGs like Eleventy support multiple templating types out of the box (though Eleventy currently doesn't handle JSX—but we will!), while others like Gatsby rely on plugin systems for different content types. Our approach mirrors this flexibility, but for simplicity, I'm focusing solely on the JSX-to-HTML renderer. The others are straightforward because most tools come with CLI utilities that handle the heavy lifting.
Working with JSX
React relies on JSX, which introduces considerable complexity. Managing webpack and babel just for that is something few want to deal with. An alternative is to use React.createElement directly, but that becomes unreadable quickly, even for mildly complex HTML. Aliasing helps somewhat. Fortunately, there's a solution that avoids transpilers entirely.
The htm library by Jason Miller (creator of numerous excellent libraries) offers a JSX-like experience using tagged template literals. This means the JSX feel without any build step. It's compact and highly performant for our needs. So, instead of JSX files, we'll use plain JS files with htm, eliminating a significant chunk of build complexity.
Implementation Overview
Here's the complete setup:
/
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
//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);
}
Our dependencies are four: htm, react, react-dom, and yargs.
The yargs dependency is not strictly necessary. You could use custom argument parsing or skip it altogether, relying on hardcoded values, environment variables, or a config file instead. I use yargs to let users customize the output and template folders via CLI, with output and templates as defaults. It also allows for future expansion.
The process iterates through files in the templates folder, skipping those beginning with _ (which indicate partials like layouts). Each page is rendered using ReactDOM.renderToString, converting JSX into HTML strings that are then written to disk. To avoid duplicating boilerplate markup on every page, a separate layout file is used. This layout slots page properties where needed. A check is also included to see if body is a function and to await its result if so. This optional feature is a nice touch, enabling static markup, dynamic props-based content, or asynchronous rendering—allowing for data fetching or file system traversal before rendering. The final output goes to the output folder with the same name as the input file, only the extension changing from .js to .html.
Here's an example of a page and its layout:
//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>
`;
Pages can carry various metadata beyond markup; I've shown some useful ones here. The body property contains the primary JSX, while title is templated into the title tag and layout points to the layout file path.
htm provides convenient shortcuts for React and Preact; we simply pick the right import. For non-React JSX-compatible libraries, manual binding to the h function is required—we'll demonstrate with React:
import htm from "htm";
import React from "react";
const html = htm.bind(React.createElement);
const myElement = html`<div></div>`;
htm also offers multiple module formats. The .mjs version is ideal for ESM, while the .js CJS variant works as well, we'll stick with the proper format.
When using React components with htm, you'll employ expressions to insert them, like <${ReactComponent} />, where the value is a React component function or class. Closing tags with htm can be omitted; the convention is <//> (though the actual tag name is ignored). As a rule, wherever JSX uses curly braces { ... }, htm uses expression syntax ${ ... }.
//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>
`;
The layout follows suit—it has standard HTML boilerplate but can insert various page elements beyond just the main content region.
Finally, here's the ensure helper:
//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);
}
}
}
This function guarantees that nested directories exist. To stay true to the article's title (since this exceeds 20 lines), you could swap the 4th dependency for mkdirp and skip parameter parsing, reducing it to 3 dependencies and roughly 10 fewer lines. I prefer avoiding dependencies when a copy-paste from my snippet collection suffices.
Execution
Running node renderers/htm-react-renderer.js will convert all files in templates into HTML pages. Options include node renderers/htm-react-renderer.js -o ./output/react/ for a custom output directory or node renderers/htm-react-renderer.js -t ./templates/react/ for a different templates folder. This is how the example builds both React and Preact versions through npm scripts.
Preact as a Lighter Alternative
For an even more minimal footprint, Preact is a viable option (my node_modules shrunk to ~2.68MB using only Preact). The example code includes a Preact renderer side-by-side to test it out and illustrate creating another renderer. You might choose one or the other based on your needs.
//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);
}
The process is identical, but we skip react-dom and ReactDom.renderToString in favor of preact-render-to-string's render function.
Pages remain the same except they 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>
`;
The _layout.preact.js file is identical to its React counterpart, so I won't duplicate it here.
Advantages Observed
This approach offers several notable benefits over existing frameworks: significantly smaller size, increased simplicity, native ESM support, and clearer error messages out of the box.
Future Extensions
I used a similar template to set up a custom SASS build, which is as easy as chaining renderers: node renderers/htm-react-renderer.js && node renderers/sass-renderer.js. This can serve as a package.json script, but a small Node script could also orchestrate the process if needed. You can apply this pattern to LESS, other templating languages, or anything else you require.
One area worth exploring is Deno compatibility. Given its simplicity, adapting it for Deno users seems entirely feasible.
Admittedly, this is a basic case of HTML output. Advanced topics like script bundling and progressive hydration are areas where framework authors invest extensive effort, and this approach may not be the most efficient path for those. But it demonstrates just how straightforward React SSG can be.
The full source code is available on GitHub.
