Skip to main content
DEPLOY1 Blog
Agency site
← All posts

A minimal static site generator within a few hundred lines

This blog runs on a hand-rolled static generator with no framework. Here is how it works: strict frontmatter validation, a constrained Markdown renderer, and plain HTML templates.

In the previous post we argued that marketing sites should be static. To prove the point, we built this blog that way: no framework, no bundler, no CSS preprocessor. The entire generator is one Node script plus two small library modules — a few hundred lines of logic, on top of the HTML templates themselves.

Here's why, and how it works.

Why not use an existing SSG?

It's a fair question. There are excellent static site generators — and for many sites they're the right tool. But for a small blog that we maintain, the cost of a generator framework is the same as any other framework: version churn, plugin ecosystems, and a build that behaves differently than the code you actually wrote.

A minimal generator has real advantages:

  • You can read all of it. Every behavior of the build is in one directory, not under node_modules.
  • It does exactly one thing. No incremental rebuild state, no watch mode, no plugin hooks we'd never use.
  • It fails loudly. A strict validator that throws on bad metadata catches let's-rebuild-later problems in CI, where they belong.

The pipeline

The build is a sequence of four steps:

  1. Load and validate every content/posts/*.md file.
  2. Render markdown to sanitized HTML.
  3. Assemble pages from templates and write them to dist/.
  4. Generate the machine-readable files: sitemap, RSS, robots, search index.

1. Strict content validation

Each post starts with frontmatter:

title: "A minimal static site generator"
slug: minimal-static-site-generator
description: "A post about building things simply."
date: 2026-09-04
category: Engineering
tags: ["static-site-generator", "node"]
author: deploy1-team

parseFrontmatter splits on the delimiter, and validatePost enforces the schema. Every field is checked — date format, category against an allowlist, description length, tag character set:

function validatePost({ meta, content, slug, file }) {
  assertString(meta, "title", file);
  assertDate(meta.date, "date", file);
  if (!CATEGORIES.has(meta.category)) {
    throw new Error(`${file}: unknown category "${meta.category}"`);
  }
  // ...
}

The key design choice: the build throws on invalid content. A typo in a slug or a description that's too short fails CI, not quietly ships.

2. A constrained Markdown renderer

We wrap marked with a renderer that has three lockdown rules:

  • Raw HTML from markdown is discarded (renderer.html = () => "").
  • Link hrefs are checked against a safe-protocol allowlist; external links get rel="noopener noreferrer".
  • Code blocks are escaped before insertion.
const renderer = Object.create(marked.renderer);
renderer.html = () => "";
renderer.code = ({ text, lang }) =>
  `<pre><code${lang ? ` class="language-${lang}"` : ""}>${escapeHtml(text)}</code></pre>`;

Even though our own team writes the content, a renderer that cannot emit raw HTML removes an entire class of bugs pre-emptively.

3. Templates and asset hashing

Pages are assembled by string templates. There's no JSX and no component system — for a site with four page shapes (home, article, category listing, 404), functions like postCard(post) are enough.

Build-time asset hashing keeps caching honest. The CSS gets a content hash:

const hash = crypto.createHash("sha1").update(source).digest("hex").slice(0, 8);

Hashed assets are served with an immutable cache header; HTML pages avoid long-lived caching — a short no-cache window with stale-while-revalidate at the edge — so new posts appear immediately.

4. Machine files

Every listing and search-ui feed is generated data, not a plugin:

  • sitemap.xml — every unique URL, including category and tag archives.
  • rss.xml — publish dates from the validated frontmatter.
  • robots.txt — pointing to the sitemap.
  • search-index.json — title, description, tags and a plain-text excerpt, consumed by a ~150-line client-side search module.

The point isn't the code

The generator is small not because we're clever, but because a blog is small. The discipline that makes it useful is the validation: human-authored content is run through an uncompromising machine check, every build, in CI.

If you're reaching for a static-site framework, ask what a minimal generator gives you first. In many cases the answer is "everything we need, with nothing we have to maintain."

Simple on the outside. Sophisticated underneath — one file at a time.