Leaving Hashnode: building a blog I actually own
Hashnode is winding down, and with it goes every post I published there, every cover image served from their CDN, and every URL people have linked to. That is the deal you make with a hosted platform, and it is usually a fine deal right up until the moment it is not.
So I rebuilt the blog inside this site. Same domain, same design language, no third party in the middle. This post is both the first one on the new system and a description of how it works.
# What actually breaks when a platform shuts down
It is worth being precise about the damage, because it is bigger than "the posts go away".
My old listing page had nine hand written cards. Every cover image was hotlinked from cdn.hashnode.com, and every title linked out to adityaseth777.hashnode.dev. So the failure mode was not one page going blank. It was:
- Nine dead outbound links from a page I control.
- Nine broken images on that same page, because I never hosted them.
- All the accumulated search ranking pointing at a domain that no longer resolves.
- No copy of the markdown anywhere except an export I had not taken.
The last one is the real lesson. Portability is a property of your source format, not of your hosting. Markdown in a git repository can be moved to anything. A post living only in someone else's database cannot.
# The shape of the replacement
The site is static HTML on GitHub Pages with no build step, and I wanted to keep that true for everything except the blog. So the blog is the one part that compiles:
content/posts/*.md markdown source of truth
|
v
scripts/blog/build.mjs Node generator (runs in CI)
|
v
blog/<slug>.html static pages, committed to the repo
blog.html regenerated listing
feed.xml / sitemap.xml regenerated
The generator is a few hundred lines of plain ES modules. No framework, no bundler, no templating engine. Pages are assembled with tagged template literals that HTML escape every interpolation by default:
export function html(strings, ...values) {
let out = strings[0];
for (let i = 0; i < values.length; i++) {
out += resolve(values[i]) + strings[i + 1];
}
return raw(out);
}
That is stricter than most templating engines, where autoescape is a setting somebody can turn off.
# Why the output is committed, not deployed from an artifact
GitHub Pages can deploy from an Actions artifact instead of a branch. I deliberately did not do that.
This repository publishes about sixty files that have nothing to do with the blog: a CV, three letters of recommendation, certification images, fonts, a PWA manifest, fourteen redirect stubs. Switching to artifact deployment would put all of them behind a packaging step, so a mistake in the blog build could 404 my resume.
Committing generated HTML costs some history noise and buys a lot:
| Property | Committed output | Artifact deploy |
|---|---|---|
| Blast radius of a build bug | Blog only | Whole site |
| Review the real JSON-LD in a PR | Yes | No |
git clone and open locally |
Works | Needs a build |
| Rollback | git revert |
Re-run a workflow |
| Broken build | Last good HTML stays live | Stale or partial deploy |
For a site whose entire premise is precision tuned structured data, being able to read the generated schema in a pull request diff is a feature, not noise.
# Making the markdown fail loudly
The generator refuses to build a post with missing or wrong metadata. Not a warning, an exit code.
title: "Leaving Hashnode: building a blog I actually own"
date: 2026-07-31
description: "Hashnode is winding down, so I rebuilt my blog as static pages..."
tags: [personal, devops]
cover:
src: images/blog/leaving-hashnode/cover.jpg
alt: "Dark cover image reading Leaving Hashnode"
A description shorter than fifty characters fails. A tag that is not in the closed vocabulary fails, and the error lists the tags that do exist. A cover file that is not on disk fails. An unclosed code fence fails. So does an em dash, because this repository has used plain hyphens since long before the blog did.
Under CI those diagnostics are emitted as workflow commands, so a bad field shows up as an annotation on the exact line of the markdown rather than buried in a log.
# Comments without accounts
The requirement was specific: anyone can comment without signing up, I can reply on the site itself, and I can delete anything. That rules out Giscus and utterances, which both require a GitHub account from the commenter.
What runs instead is a single Cloudflare Worker on a same origin route. The domain is already proxied through Cloudflare, which means the API can live at /api/v1/... on this same hostname. No CORS layer, no preflight request, no separate subdomain to remember.
Storage is D1, Cloudflare's SQLite. The comment table is unremarkable except for two columns worth explaining:
ip_hash TEXT NOT NULL, -- SHA-256(ip + UA + daily-rotating salt)
owner_token TEXT NOT NULL -- returned once, kept in the commenter's localStorage
The ip_hash supports rate limiting and view deduplication, but the salt rotates daily, so it is not a durable identifier for anybody. The owner_token lets an anonymous commenter delete their own comment later without ever creating an account.
Moderation happens on the live post. When my Cloudflare Access session cookie is present, delete buttons and a "reply as author" affordance appear inline next to each comment. There is no separate admin application to maintain, and I answer people exactly where they asked.
# The view counter, and why it cannot be static
This one is worth spelling out, because it is the part that looks impossible on a static site.
The pages are files. They are generated at build time and cached at the edge. A number baked into the HTML would be frozen at the last deploy, and it would be wrong for everyone who reads the page afterwards.
So the count is a hydrated island. The HTML ships an empty element, and JavaScript fills it:
const res = await fetch(`${api}/views/${slug}`, { method: 'POST' });
const { count } = await res.json();
On the server, the increment is deduplicated before it ever reaches the database:
const key = `seen:${ipHash}:${slug}`;
if (await env.KV.get(key)) {
return json({ count: await readCount(env, slug) }); // refresh, no write
}
await env.KV.put(key, '1', { expirationTtl: 86400 });
return json({ count: await incrementCount(env, slug) });
Refresh the page fifty times and the number moves once. Key value reads are edge cached and effectively free; the database only sees a write when the visitor is genuinely new that day.
Two details matter for honesty. Before the request resolves, the element is hidden rather than showing a zero, and if the request fails it stays hidden. A missing number is fine. A confidently wrong number is not.
The other detail is bots. This site's robots.txt explicitly invites about twenty eight AI crawlers, which is intentional, but none of them should inflate a human read count. They mostly do not execute JavaScript, and the Worker rejects known crawler user agents anyway.
# What I gave up
Being fair about the trade: Hashnode gave me a built in audience, a newsletter, and an editor that worked from any device. I have none of those now. Publishing means writing a markdown file and pushing it.
For me that is the right trade, because the thing I actually wanted was for the writing to still exist in ten years. But it is a trade, not a free upgrade, and anybody making the same move should know which side of it they are on.
# What is next
The nine old posts are still on the listing page as external cards while Hashnode is up. Migrating them properly means pulling the markdown through Hashnode's GraphQL API, rehosting the covers here, and pointing redirects at the new URLs. That is the next job, and it is mechanical now that the pipeline exists.
If you have opinions about any of this, the comment box below does not want your email address.
Frequently asked
- Why not just move to another hosted blogging platform?
- Because the same thing would eventually happen again. Every hosted platform owns your URLs, your images, and your comment data, and every one of them can change terms or shut down. Markdown files in a git repository are portable to anything.
- How much does this setup cost to run?
- Nothing at current traffic. GitHub Pages hosts the static files for free, and the comments and view counter run on the Cloudflare Workers free tier, which allows 100,000 requests per day. A personal blog uses well under one percent of that.
- Do readers need an account to comment?
- No. Comments are fully anonymous, with an optional name field. Spam is handled by an invisible Cloudflare Turnstile challenge and a profanity filter that runs before anything is stored, so there is no signup wall in front of a two sentence reply.
- How are page view counts kept accurate across refreshes?
- The counter increments at most once per browser per post per day. A hash of the visitor address and user agent is written to a key value store with a 24 hour expiry, and the database is only updated when that key is absent, so refreshing the page does not inflate the number.
Comments