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.
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")
@file:DependsOn("org.commonmark:commonmark-ext-gfm-tables:0.24.0")
Three dependencies, built with one command: kotlin site/build.main.kts. There's no Gradle project and no node_modules to maintain.
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
---
In detail:
posts/-> blog postspages/-> routes like/page/speaking/seriesSlug-> generated series landing pagesdraft: true-> visible locally, excluded from productionsite/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. Pulling the content over was trivial. The real work was deciding what to leave as a draft, because a lot of the old posts were garbage.
The review flow was:
- Import posts and images
- Keep everything as drafts
- Preview locally with all drafts visible
- Delete the duplicates and weak posts
- 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
It's a small thing platforms routinely get wrong: you shouldn't have to fake-publish a post just to check how it lays out.
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:
- Baseline — screenshot the page at desktop (1440) and mobile (390).
- 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.
- Refine — layout, typeset, colorize, animate.
- Simplify — adapt for responsive, clarify the copy, distill out anything that doesn't earn its place.
- Harden — accessibility, Core Web Vitals, a final polish pass.
- 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.mdwith 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.
The shared lift-in entrance gated visibility with opacity: 0, so the production series list shipped blank on first paint until the animation ran. The CSS reads fine; only a headless browser rendering the page catches it.
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
, a space-separated attribute CommonMark can't parse, so 38 images across 7 posts were leaking through as literaltext. A smallnormalizeContentMarkdown()step inbuild.main.ktsstrips 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. The scores matter less than where they came from: 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 got better. You can't do 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. There's no server to patch and no CMS backend to babysit.
What I Gained
My posts are just files. Plain markdown in a git repo, nothing more.
I own the rendering. If I want series pages, I add them. If I want drafts visible locally but hidden in production, I build 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 and small pages, served straight from the GitHub Pages CDN.
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 in the next directory, not reconstructed from memory in a throwaway example.
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/
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.
A personal blog is small enough that you can own it end to end. The Kotlin SSG is incidental: use whatever stack you like, as long as you own the whole thing.
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 sit in the same workspace and benefit from the same setup: when I write about them, I'm writing about code I actually run.
Claude Code
Practical workflows, hooks, and planning patterns for using Claude Code in real projects.