Why I Left Hashnode and Built My Own Blog with a Kotlin SSG

I've been blogging on Hashnode for years. It used to be fine. Write a post, publish it, move on.

Then it got in the way.

  • The raw markdown editor got replaced by a block editor.
  • The UI got heavier.
  • The GitHub sync path was unreliable.
  • My real writing workflow was already happening in IntelliJ anyway.

At that point Hashnode was not helping me publish. It was just the last copy-paste step after the writing was already done. So I removed it.

A blog made of plain files in a git repo is agent-native. I can point Claude Code at the whole thing — posts, templates, the build script, even my old session transcripts and handoff notes — and have it actually do the work. No hosted platform gives you that.

The Site

I didn't want another generic developer blog that looks like every Tailwind landing page clone on the internet.

So I iterated directly on the templates with Claude Code until the site felt like mine:

  • IBM Plex Mono as the one and only typeface
  • a near-black background (#050b12) with a single cyan accent (#12d7ff)
  • a compact homepage hero with a ›_ terminal-console "Jump in" panel instead of a generic blog intro
  • series on their own landing pages instead of dominating the homepage
  • standalone pages like /page/speaking/

The nice thing about plain templates is that redesigns are cheap. If I want to move the nav, change the hero, or completely rethink the homepage, I edit HTML and CSS. There is no theming system to fight.

The Impeccable Loop

Getting every page to a standard I'd actually be proud to ship took more work, and this is where owning the templates paid off.

I ran every page through what I call the impeccable loop: a repeatable design-review pass driven by Claude Code with the Impeccable skills, and verified in a real browser instead of in source. One subagent owns one page at a time and walks it through the same steps:

  1. Baseline — screenshot the page at desktop (1440) and mobile (390).
  2. Evaluate — two independent design reviews score it: Nielsen's 10 heuristics out of 40, plus a technical audit out of 20. Those collapse into a single 0–100 composite.
  3. Refine — layout, typeset, colorize, animate.
  4. Simplify — adapt for responsive, clarify the copy, distill out anything that doesn't earn its place.
  5. Harden — accessibility, Core Web Vitals, a final polish pass.
  6. Re-score and close — the page only passes at a composite ≥ 90, an audit ≥ 18/20, and zero open high-severity issues.

In detail:

  • every step screenshots desktop + mobile so regressions are caught in the rendered page, not imagined in the markup
  • the score has to rise over baseline and clear the bar, or the page doesn't close
  • each page gets a REPORT.md with a baseline→final gallery
  • everything lands in an append-only ledger so I can see exactly what changed and why

The rule that mattered most: verify in the browser, never in the source. An animation that looks fine in CSS can still ship a blank page on first paint.

That rule earned its keep. The shared lift-in entrance gated visibility with opacity: 0, so the production series list actually shipped blank on immediate load until the animation ran. Reading the CSS, it looks fine. Only the rendered page in a headless browser gives it away, which is exactly what the loop forces you to look at.

A few fixes spanned more than one template, so I pulled those up to an orchestrator instead of leaving them to the per-page passes:

  • Broken images — Hashnode exported images as ![](URL), a space-separated attribute CommonMark can't parse, so 38 images across 7 posts were leaking through as literal ![](…) text. A small normalizeContentMarkdown() step in build.main.kts strips those trailing attributes before parsing.
  • The blank-on-load animation above, fixed in all four templates at once.
  • Shared chrome — footer social links got rel="me noopener", and nav links, back-links, and footer icons became proper 44×44px touch targets.

All five pages cleared the bar. Forget the specific scores for a second. What I actually gained is that because the blog is just templates in a repo, I can run a rigorous, repeatable review over the whole thing with an agent and a browser, and prove it improved. Try doing that to a theme you rent from a platform.

Deployment Is Boring Again

GitHub Pages with a dead-simple workflow:

# .github/workflows/deploy.yml
- name: Build site
  run: kotlin site/build.main.kts

- name: Upload Pages artifact
  uses: actions/upload-pages-artifact@v3
  with:
    path: dist/

Push to main, GitHub Actions builds the site, and Pages serves the artifact. No server to patch. No container image. No CMS backend. No database. Perfect.

What I Gained

My posts are just files. Markdown in git. That is the whole game.

I own the rendering. If I want series pages, I add them. If I want a speaking page, I add it. If I want drafts visible locally but hidden in production, I implement that once and move on.

I own the workflow. Writing, previewing, reviewing, and publishing now happen in one repo. No copy-paste bridge to a web editor.

It is fast. Static HTML, small pages, GitHub Pages CDN. No loading spinner pretending to be architecture.

The blog is agent-native. The whole Impeccable Loop above only works because the design is files an agent can read, edit, and verify in a browser. The blog also sits in the same workspace as my other projects, so when I write about a pattern I used in PhotoQuest or JVM Skills, the code is right there. No hand-wavy reconstruction. No fake example project just for the blog.

With Hashnode, the blog was isolated from the actual work, and an agent couldn't touch any of it. With this setup, the blog is part of the work.

Orchestrator/
├── PhotoQuest/
├── jvm-skills/
├── claude-code-for-spring-developers/
└── blog/
    └── posts/
        ├── claude/
        ├── htmx/
        └── spring-boot/

What I Built

A small Kotlin static site generator that does exactly what I need:

// site/build.main.kts
@file:DependsOn("org.yaml:snakeyaml:2.3")
@file:DependsOn("org.commonmark:commonmark:0.24.0")

Two dependencies. No Gradle build. No npm toolchain. Just kotlin site/build.main.kts and the site is built.

Posts live as markdown files in posts/ organized by topic:

posts/
├── claude/
├── htmx/
├── spring-boot/
├── devops/
└── kotlin/

Standalone pages live in pages/, for example my speaking page:

pages/
└── speaking.md

Each post has YAML frontmatter:

---
title: "My Post Title"
slug: my-post
tags: spring-boot, htmx
coverImage: my-cover.png
seriesSlug: full-stack
description: "One-line summary for the post listing and Open Graph."
draft: true
---

The generator now handles:

  • posts grouped by topic
  • standalone pages
  • series pages
  • draft filtering for production
  • draft preview in local dev
  • live reload while editing
  • static output to dist/

In detail:

  • posts/ -> blog posts
  • pages/ -> routes like /page/speaking/
  • seriesSlug -> generated series landing pages
  • draft: true -> visible locally, excluded from production
  • site/watch.sh -> rebuild + browser reload during writing

That is the right level of complexity for a personal blog. I still want series and preview, but I do not need a CMS, plugin ecosystem, or frontend build pipeline.

The Migration

I used the Hashnode GraphQL API to pull my old posts with markdown and cover images:

curl -s 'https://gql.hashnode.com' \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ publication(host: \"tschuehly.de\") { posts(first: 50) { edges { node { title slug publishedAt content { markdown } coverImage { url } } } } } }"}'

Then I imported everything into the repo as markdown files and kept every migrated post as a draft first. That part matters. Migrating content is easy. Migrating content without publishing garbage is harder.

The review flow was:

  1. Import posts and images
  2. Keep everything as drafts
  3. Preview locally with all drafts visible
  4. Delete the duplicates and weak posts
  5. Undraft only the posts that were actually already published

That last step exposed one of the benefits of owning the generator: local preview can show drafts with a visible draft badge while production still excludes them.

# local preview with drafts + live reload
bash site/watch.sh

That sounds minor, but it is the kind of workflow detail platforms get wrong. A blog should support writing and review. It should not force fake publishing just so you can check the layout.

The Stack

Component Tool
SSG Kotlin script (CommonMark + SnakeYAML)
Templates Plain HTML + CSS
Fonts IBM Plex Mono
Local preview bash site/watch.sh
Hosting GitHub Pages
CI/CD GitHub Actions
Writing IntelliJ + Claude Code

If you're frustrated with your blogging platform, replace it before you spend another year complaining about it.

The point is not that everyone should write a Kotlin SSG. The point is that a personal blog is small enough that you can own it end to end.

If you want to learn more about Claude Code workflows, check out my other posts in the claude-code series.

My side business PhotoQuest and my project JVM Skills both benefit from the same workflow: real code, real context, fewer fake boundaries.

Claude Code

Practical workflows, hooks, and planning patterns for using Claude Code in real projects.

  1. How I Used Claude Code to Plan a Complex Security Migration using AskUserQuestionTool 2026-01-11
  2. Fixing Gradle on Claude Code Web with a PreToolUse Hook 2026-02-23
  3. Why I Left Hashnode and Built My Own Blog with a Kotlin SSG 2026-04-23