Cobra Developers
Blog, Engineering

Build a static site generator in one file with no dependencies

The short answer

A static site generator is a script that reads your content as data and writes finished HTML files. In Node you can build one with no dependencies: import a data module, write each page as a function that returns a template literal, loop over the data, and write the results to an output folder along with a sitemap and feed.

This site is built by one file. It is called build.mjs, it runs with node build.mjs, and the project's package.json lists no dependencies at all. There is no framework, no bundler, and no node_modules folder to install before the build can run.

We did not do this to make a point. We have written generators for client sites for a while, including the 305 static pages of Gayer Law and the service and city pages of EZ Swap, which come from a single Python script. When our own site needed building, a script was the tool we already trusted.

Here is how it is put together, and where a framework would have been the better call.

What does a static site generator actually do?

A static site generator takes content in a structured form, runs it through templates, and writes plain HTML files to a folder. A web server then hands out those files as they are. Nothing is computed when a visitor arrives, which is why static sites are fast, cheap to host, and hard to break.

Stripped down, every generator does four jobs:

  1. Load the data: projects, posts, business details.
  2. Derive what the templates need, such as counts, lookups, and sort orders.
  3. Render each page to a string of HTML.
  4. Write the strings to files, and copy over the CSS, scripts, fonts, and images.

Everything else a framework offers is convenience layered on those four steps. If your site is small enough, you can do them directly. For the wider picture, see what a static site generator is.

Where does the content come from?

The content lives in ordinary JavaScript modules that export data. Because the build is itself JavaScript, loading content is an import statement with no parsing step, no database, and no content API. The file that describes our projects is the single source of truth for every page that mentions one.

The top of the build script is the whole dependency list, and every entry is built into Node or is a file in the repository:

import { mkdirSync, writeFileSync, rmSync, cpSync, existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { projects, kinds, statusLabel } from './data/projects.mjs';
import cfg from './site.config.mjs';
import { parse as parseMd } from './tools/md.mjs';

Next comes derivation. The script works out things no one should type by hand:

const norm = (t) => t.replace(/\s+\d+(\.\d+)?$/, ''); // "Next.js 16" and "Next.js 15" count as one technology

for (const p of projects) {
  p.tech = [...new Set(p.stack.map(norm))];
  p.shot = hasShot(p);
}
const bySlug = Object.fromEntries(projects.map((p) => [p.slug, p]));

The project count in the footer, the number of live projects, and the list of technologies are all computed here. Add a project to the data file and every figure on the site is correct on the next build. This is the same idea as generating local service pages from one data file, applied to ourselves.

How do you write templates without a template engine?

JavaScript template literals are a template engine. A page is a function that takes data and returns a string, with ${} for values and .map().join('') for loops. The one thing you must add yourself is escaping, so that text containing an angle bracket or a quote cannot break the HTML.

Our escape helper is one line, and it wraps every value that comes from data:

const e = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

A single page() function produces the shared shell: the head, the header, the footer, and the metadata. Every page calls it with a URL, a title, a description, and a body. Here it is, cut down heavily:

function page({ url, title, desc, body, ld, cls = '', ogType = 'website' }) {
  return `<!doctype html>
<html lang="en">
<head>
<title>${e(full)}</title>
<meta name="description" content="${e(desc)}">
<link rel="canonical" href="${origin}${url}">
<script type="application/ld+json">${JSON.stringify(ld ? [orgLd, ld] : orgLd)}</script>
</head>
<body class="${cls}">
<main id="main">
${body}
</main>
</body>
</html>`;
}

Small components are just smaller functions. One returns a screenshot with its picture element, another returns a row of technology buttons. There are no partial files and no special syntax, so an editor's ordinary JavaScript tooling works on the templates.

The honest weakness is that escaping is manual. Forget the e() call and nothing warns you. A template engine that escapes by default removes that risk, and on a site with content from many authors we would want it.

How do the files get written?

Writing the site is a short sequence: delete the old output folder, copy the static assets, then write each rendered page to a path that matches its URL. A page at /work/gayer-law/ becomes work/gayer-law/index.html, which any static host serves at the clean address.

rmSync(dist, { recursive: true, force: true });
const write = (rel, text) => {
  const f = path.join(dist, rel);
  mkdirSync(path.dirname(f), { recursive: true });
  writeFileSync(f, text);
};
cpSync(path.join(root, 'src'), dist, { recursive: true });
write('index.html', home);
write('work/index.html', work);
sorted.forEach((p, i) => write(`work/${p.slug}/index.html`, casePage(p, i)));
posts.forEach((p, i) => write(`blog/${p.slug}/index.html`, postPage(p, i)));

The CSS and JavaScript in src are copied as they are. Nothing is bundled or minified, so the files a visitor's browser receives are the files we edit.

The build also writes a projects.json file from the same data. The terminal, the technology popovers, and the homepage graph all read it in the browser, so the interactive parts cannot disagree with the printed pages.

How do you add a sitemap, a feed, and a blog?

A sitemap and an RSS feed are text files with a fixed shape, so they are two more template literals over lists the build already has. A blog needs one extra piece, a Markdown parser, which can be small if you decide in advance which parts of Markdown your posts are allowed to use.

The sitemap is built from the same arrays that produced the pages, which means a page cannot exist without being listed:

const urls = ['/', '/work/', '/services/', '/blog/', '/company/', '/contact/', '/privacy/',
  ...sorted.map((p) => `/work/${p.slug}/`), ...posts.map((p) => `/blog/${p.slug}/`)];
write('sitemap.xml', `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${origin}${u}</loc></url>`).join('\n')}\n</urlset>\n`);

Posts are Markdown files in a folder. Our parser, tools/md.mjs, is about a hundred lines. It reads the front matter as simple key: value lines, then walks the body one line at a time and recognises headings, paragraphs, lists, quotes, tables, and fenced code. It supports nothing else, on purpose. While it parses, it also collects the table of contents and counts words for the reading time.

PieceHow ours is done
ContentJavaScript modules and Markdown files
TemplatesFunctions returning template literals
AssetsCopied unchanged from one folder
Sitemap and feedTemplate literals over the page lists
MarkdownA small parser limited to what posts use
DependenciesNone beyond Node

When is a framework the better choice?

A framework is the better choice when the site has parts that a script does not give you for free: interactive components that share state, image resizing, many authors, previews for editors, or pages that need a server. A hand-written generator suits sites whose pages are fixed at build time.

The trade-offs we accepted, stated plainly:

  • No image pipeline. Screenshots are prepared by a separate script, not during the build.
  • No incremental builds. Every run rebuilds everything. At this size that is not a concern. At tens of thousands of pages it would be.
  • No guard rails. Escaping and link checking are our job. We run a separate validator over the blog posts for that reason.
  • It is ours to maintain. A new developer has to read the script, where a framework would come with documentation.

That last point cuts both ways. There is no upgrade treadmill and nothing for a dependency update to break, because there are no dependencies. For a brochure site, a set of generated service pages, or a blog, that is a good trade. For a product with accounts and dashboards, we use a framework, as we do with Next.js on CoverageCobra and Regulars.

Frequently asked questions

Is a custom static site generator harder to maintain than a framework?

It depends on who maintains it. A few hundred lines of plain JavaScript are easy for any developer to read, and they never need upgrading. A framework is easier to hand to a stranger because its conventions are documented. Keep the script short and commented.

Can a non-developer edit a site built this way?

Not comfortably. Content lives in data files and Markdown, edited in a code editor and published by a build. If the owner needs to log in and change text, a content management system, or a headless one feeding the generator, is the better fit.

How long does the build take?

A build that only joins strings and writes files is quick, because there is very little work in it. Build time becomes a concern when a generator processes images or renders very large numbers of pages, which is where incremental builds earn their keep.

Does this approach work in languages other than JavaScript?

Yes. The pattern is data in, strings out, files written, and any language can do it. Some of our client generators, including the One Stop Bail Bonds and EZ Swap builds, are Python scripts. JavaScript has one advantage on the web: template literals make multi-line HTML easy to write inline.

Where do you host a site like this?

Anywhere that serves files. The output is a folder of HTML, CSS, and assets, so any static host or content delivery network will do, and moving hosts means copying the folder.

If you have a site that should be a folder of fast files and is not, tell us about it.

Who wrote this

The Cobra Developers team. We are a software studio in Arizona that builds and runs its own products and builds websites, web apps, and automation for clients. This post draws on our work on Gayer Law, EZ Swap. Everything here comes from projects we shipped; see all 30 of them.

guest@cobra: ~