Building my own blog engine in an afternoon

  • static-sites
  • node
  • markdown
  • tooling

I wanted a blog. Not a platform, not a CMS, not a subscription — a place at ikoonman.io where I could write a Markdown file, run one command, and have it appear. Free to run, fast to publish, and simple enough that I would still understand it six months later.

So I did the sensible thing first and went looking at what already exists. WordPress was the obvious candidate, and it is genuinely good at what it does — but it is a database, a PHP runtime, an admin interface, an update treadmill and a security surface, all standing between me and a text file. That is a fair trade for a site with editors, plugins and comment threads. For one person publishing occasional posts it is an installation to maintain in exchange for nothing I actually wanted.

That pointed me at the open-source static site generators instead, which are much closer to the right shape. I picked one, installed it, and then spent the next stretch of time doing everything except writing: reading theme documentation, working out which of three config files owned a setting, tracing why a layout override was ignored, and discovering that the small visual change I wanted lived somewhere inside a theme I had not written and did not want to learn.

None of that was the generator’s fault. It was solving a much bigger problem than mine. But at some point the frustration tipped over into curiosity: how much of this do I actually need?

The honest answer turned out to be very little. A couple of hours later I had a working engine, and about thirty minutes after starting it I had a real post rendered and published. This project was an absolute pleasure to work on — one of those rare ones where the scope stays exactly where you put it.

What it actually is

The whole engine is one Node script, three HTML templates and a stylesheet:

build.js       the entire build, ~25 kB, read top to bottom
templates/     layout.html, post.html, index.html, contact.html
src/styles.css the whole design
blog/          Markdown posts, one flat folder
public/        assets copied verbatim into dist/
dist/          generated output — safe to delete, never edited by hand

That comes to roughly 43 kB of source in total. Five npm dependencies do the heavy lifting: markdown-it for rendering, markdown-it-anchor for heading links, gray-matter for frontmatter, highlight.js for code, and fs-extra for file operations.

There is no database, no admin interface, and nothing to install on the server beyond Node and nginx. The build runs on my laptop or over SSH and produces plain static HTML files; nginx serves those files and does nothing else. Nothing executes when a visitor loads a page, so there is no login to protect, no schema to migrate, and no backup to take that a git clone does not already cover. The posts are the backup — they are Markdown files in a folder.

Writing a post means creating blog/<slug>.md:

---
title: A clear, specific title
date: 2026-09-10
tags:
  - static-sites
slug: optional-override    # defaults to the filename
draft: false               # drafts are excluded from the build
description: Optional      # otherwise the first paragraph is used
---

Markdown, raw HTML, and inline `style` attributes all work.

blog/my-post.md becomes dist/blog/my-post/index.html, served at /blog/my-post/. Only title and date are required. A missing title, an unparseable date, or a slug that collides with an existing post fails the build loudly — I would much rather see an error in the terminal than a broken page on the live site.

How the build works

The pipeline is deliberately linear. Nothing is incremental, nothing is cached, and the whole site rebuilds in milliseconds because there is almost nothing to do.

flowchart TD
    A["blog/*.md"] --> B["gray-matter: split frontmatter from body"]
    B --> C["Validate: title, date, unique slug"]
    C --> D["markdown-it: render body to HTML"]
    D --> E["Custom fence rule"]
    E --> F["mermaid fence → pre.mermaid, rendered in the browser"]
    E --> G["Other languages → highlight.js at build time"]
    F --> H["Post objects: slug, dates, tags, reading time, excerpt, prev/next"]
    G --> H
    I["templates/*.html"] --> J["{{name}} substitution"]
    H --> J
    J --> K["dist/"]
    L["public/ + docs/banners/"] --> K
    M["src/styles.css + code theme"] --> N["Concatenate, hash, styles.HASH.css"]
    N --> K
    K --> O["Post pages, homepage, archive, contact"]
    K --> P["rss.xml, sitemap.xml, robots.txt"]

The key point the diagram makes is that there is exactly one path from a Markdown file to a page, and one templating mechanism holding it together. Posts are loaded and validated, rendered to HTML, decorated with derived metadata, poured into templates via {{name}} substitution, and written to disk alongside the copied assets and the feed. There is no plugin system, no theme layer, no lifecycle hooks. When something looks wrong on the page, the code that produced it is in one file and I can find it by reading downwards.

The template engine is four lines:

function render(template, vars) {
  return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, key) => {
    const value = vars[key];
    return value === undefined || value === null ? '' : String(value);
  });
}

That is the entire abstraction. No conditionals, no loops, no partials. Anything that needs logic is a small JavaScript function that returns an HTML string, which is a perfectly good template language when you already know JavaScript.

What it supports

Markdown, with the typographer on. Standard CommonMark plus smart quotes, dashes, and automatic linkification of bare URLs. Headings at levels 2–4 get permalink anchors.

Raw HTML and inline CSS. html: true is set intentionally. When Markdown is not expressive enough — a two-column block, a bit of colour, a <details> disclosure — I drop into HTML in the middle of a post and carry on. This is a single-author site, so the usual reason to sanitise Markdown does not apply; the escape hatch is worth more than the restriction.

Embedded media. Anything in public/ is copied to the root of dist/, so public/images/diagram.webp is referenced as /images/diagram.webp. Video works the same way — a plain <video> tag with a file from public/, no plugin or shortcode involved.

An image viewer. After the page loads, every image in the post body is wrapped in a button that opens it in a native <dialog> lightbox. Using the browser’s own dialog element means Escape, focus trapping, and backdrop clicks all work without me implementing any of them. If an image has a data-full attribute pointing at a larger original, the lightbox opens that instead, so pages can ship a smaller display copy.

Mermaid diagrams. A fenced block tagged mermaid is emitted as <pre class="mermaid"> rather than highlighted as code, and Mermaid 11 renders it in the browser. The loader script is only injected into pages that actually contain a diagram — the build checks the rendered HTML for class="mermaid" and adds the module import only where it is needed. Every other fenced block is highlighted at build time, so ordinary posts ship no JavaScript for code at all.

Syntax highlighting with no client-side cost. highlight.js runs during the build and its GitHub theme is concatenated onto the stylesheet, which is then content-hashed into styles.<hash>.css so a design change invalidates caches by itself.

RSS, sitemap, robots. The feed carries the twenty most recent posts with full content, not truncated summaries. The sitemap lists every published URL with the post’s own date as lastmod.

Derived metadata I never have to type. Reading time from a word count, an excerpt taken from the first real paragraph when no description is given, previous/next navigation between adjacent posts, and tag lists.

A contact form with no backend. The form posts to Web3Forms, which relays submissions to email. A static site stays static.

Rotating banners. Each page picks a banner image at random, but tracks what it has shown in sessionStorage so every image appears once before any of them repeats. Intrinsic width and height are read directly out of the PNG, WebP, or SVG header at build time — about forty lines of byte-offset parsing instead of an image library — so the page reserves the right space and does not jump while the image loads.

Publishing

Local development is npm run dev: a watcher on blog/, templates/, src/, public/ and the banners folder, a debounced rebuild, and a small static server on port 3000. npm run drafts does the same but includes posts marked draft: true, so unfinished writing is visible to me and invisible to everyone else.

Releases are a tarball with a checksum. package.sh builds the archive, deploy.sh installs the engine on the server and installs dependencies there, and publish.sh rebuilds the live site from whatever is currently in the server’s blog/ directory. Deployment never overwrites posts or uploaded images, and both scripts take a lock directory so a deploy and a publish cannot interleave. Publishing a new post is: copy the Markdown up, run publish.sh. No pipeline, no build minutes, no vendor.

Was it worth it?

For a personal blog, easily. The trade is real and worth stating plainly: I gave up an ecosystem. There are no plugins, no themes, no community answers to search when something breaks, and no one else maintaining it. If this were a site with several authors, non-technical editors, comments, or requirements I could not predict, that would be the wrong trade — I would install WordPress or learn a mature generator’s theme system properly, and be glad the work had already been done.

But my requirements were fully known on day one, and they were small. The cost of learning someone else’s abstraction over a large problem turned out to be higher than the cost of writing my own small solution to a small one. And there is something genuinely pleasant about a project where reading the source is reading the documentation — where the answer to “why does the page look like that” is always about forty lines away.