A Blog Engine in One JSON File

26 July 2026 · technology writing

The blog listing on this site has live search, tag filters with counts, pagination, read-time estimates, and a "related posts" section under every article. I refused to use a blogging platform (Wordpress, Blogger), and frankly felt too lazy to implement a database. The entire engine is one JSON file, about three hundred lines of plain JavaScript, and two Python scripts I run when publishing. This post explains the arrangement, because I think it's the sweet spot for a personal blog, and it's a chapter in the larger story of how this site is built.

Quick jargon guide

  • JSON: a plain-text format for structured data, readable by both humans and every programming language.
  • CMS (content management system): software like WordPress that stores your posts in a database and assembles pages on demand.
  • Static site generator: a tool (Jekyll, Hugo) that compiles text files into HTML at build time.
  • TF-IDF: a classic text-analysis technique that scores how characteristic a word is for a document: words frequent in this post but rare elsewhere define what it's about.
  • Cosine similarity: a measure of how alike two documents are, computed from their word scores.

The single source of truth

Everything hangs off data/posts.json: a flat list, one entry per post, currently dozens of them. An entry is exactly this:

{
  "title": "My Website Has a Test Suite",
  "date": "2026-07-17",
  "tags": ["technology"],
  "category": "Technology",
  "excerpt": "A Python audit script and headless browser smoke tests run on every push. Here's why a personal blog has a test suite.",
  "url": "blog/my-website-has-a-test-suite.html",
  "image": "img/photography/thumb/4.webp",
  "series": { "name": "How This Site Is Built", "part": 8 },
  "readMinutes": 7,
  "words": 1534
}

Publishing a post means writing the HTML page and adding one entry to this file. The same file then feeds many things: the blog listing, the search index, the tag buttons, the footer's "latest writing" list, the site-wide command palette, the "next/previous post" links, and the related-posts scoring. One file, many consumers, no synchronisation problems, because there is nothing to synchronise. Laziness can force some ingenuity, I like to tell myself.

The front end

The listing page fetches the JSON, sorts by date, and renders cards nine to a page. Search is an ordinary text input that filters as you type, matching against title, excerpt, and tags. The tag buttons aren't hardcoded anywhere: the script counts how often each tag appears across all posts and renders one button per tag, labelled with its count, sorted by popularity. Click two tags and you get posts matching either; click "All" and you're back to everything.

Because tag buttons are generated from the data, a typo in the JSON doesn't produce an error, it produces a new button: misspell "photography" once and the blog begins offering a "phtography" filter containing one post. The fix is more careful typing, but it's also a test suite: my audit script holds the list of allowed tags, and anything outside it fails the build. This does mean adding a tag has an extra step, but so far my tags are broad so I've had no issues.

Pine branches with small cones catching low sun, seen through a chain-link fence
© Ken Reid. All rights reserved.

The publish-time scripts

Read times are computed. A Python script extracts the body text of each post (only the article region, ignoring scripts and navigation), counts the words, and divides by 220 words per minute, rounding up. The result is baked into the JSON as readMinutes. I picked 220 because it's a middle-of-the-road adult reading speed; the exact number is just an estimate so people know what they're getting into, I doubt many people time it!

WORDS_PER_MINUTE = 220

post["readMinutes"] = max(1, -(-words // WORDS_PER_MINUTE))  # ceil division
post["words"] = words

Related posts are great for keeping visitors interested. A second script reads the full text of every post and builds TF-IDF vectors: each post becomes a list of scored words, where words common in that post but rare across the blog score highest. The title counts double, since it's the strongest signal of what a post is about. Related candidates are ranked by cosine similarity, with a small bonus of 0.06 per shared tag, and the top three get baked into the page as static HTML. The whole ranking is four lines of code:

# how alike is this candidate's vocabulary to the current post's?
# 0.0 = nothing in common, 1.0 = identical word profile
sim = cosine(vectors[current_url], vectors[url])

# how many tags do the two posts share? (usually 0 or 1)
shared = len(current_tags.intersection(post.get('tags', [])))

# final score: word similarity plus 0.06 per shared tag.
# the date rides along as a tiebreaker, newer post wins
ranked.append((sim + TAG_BOOST * shared, post.get('date', ''), post))

# ... every candidate scored, then: sort and keep the best three
return [post for _score, _date, post in ranked[:3]]

The related posts you see under this article were chosen when it was published, by a script that read every post on the site. Doing that in the browser would mean shipping the full text of dozens of posts to every visitor. Doing it at publish time costs the visitor nothing, and the recommendations reflect content, not just matching tags. When any post is published, the script re-bakes every page, so older posts learn about newer ones.

Why not Jekyll?

GitHub Pages has Jekyll built in, and for most people starting a blog I'd recommend it without hesitation. This site actually predates its blog by a good margin: it began as a simple portfolio back in 2012 or so, then grew into a photography portfolio built from an HTML template, and bolting a static site generator onto an existing hand-crafted site means converting everything to its conventions. And, also importantly, I like doing this. It's a fun project that I learn from, and provides me with more context of the modern web than my 2010 class on portlets, XSLT and DOM.

But, that means that each post is a full HTML file. I have tricks up my sleeve for making it easier, which I describe throughout this series, and for the few dozen blogs I've written so far it's been worth it. Ask me again at 500.

Common questions

Doesn't editing JSON by hand invite mistakes?

Constantly, which is why the audit script validates every field.

Why not compute related posts by tags alone?

Tags are coarse: this blog has dozens of posts and 13 allowed tags, so "personal" matches half the site. TF-IDF looks at the actual words, so a post about Scottish hospitals finds other posts about Scotland and photography rather than three random "personal" entries.

What happens when the JSON and the HTML disagree?

The JSON wins, because everything visitors see (cards, search, links) comes from it. The audit exists to make disagreement impossible in practice: a post page without a JSON entry is invisible, and a JSON entry without a page fails the build.

Could I copy this approach?

Yes, read more of this series to grab snippets and check out my github repo!


Back to all posts