4 min read

Teaching Ghost to Speak IndieWeb

How I retrofitted my Ghost blog with an h-card and webmentions — two things Ghost doesn't ship with.

Where this started

I like Ghost. It's lightweight, open source, writes well, and stays out of the way. But Ghost was born as a publishing and newsletter platform — not as a citizen of the IndieWeb. It doesn't publish an h-card for my identity, doesn't mark up my posts with h-entry, and has no idea how to send or receive webmentions. To a machine, my site was just nice-looking HTML: humans could read it, software understood almost nothing.

The idea behind this experiment was simple: keep Ghost exactly as it is — no plugin, no fork, no extra server humming away beside it — and still get my site talking to the open web. Everything you see here lives in the theme (Handlebars plus a little CSS and JavaScript) and in one free hosted service. Not a single line of server code.

And I gave myself one non-negotiable rule: don't lose 1% of the look. The blog is a lean little list, almost a retro directory. Anything I added had to be either invisible or as understated as everything else.

Identity first: the invisible h-card

Everything on the IndieWeb starts with proving who you are. You do that with microformats — a handful of class names you bolt onto your existing HTML so software can pick out your name, photo, URL, email, and profiles. The h-card is the semantic business card.

In my case, I didn't want a card showing up on screen; I wanted only machines to read it. The solution was a hidden h-card block at the top of every page, using the hidden attribute. Readers see nothing; IndieWeb parsers read the HTML just fine, because they ignore CSS.

Alongside it, I scattered rel="me" links to my profiles (GitHub, Codeberg, SourceHut, Mastodon, and so on). That's what cements "this domain and these accounts are the same person." The detail almost everyone forgets: rel="me" only closes the loop if the profile links back to your domain too. Without the return link, the verification never happens.

Posts machines can read: h-entry

Identity sorted, the posts were next. h-entry is the microformat that marks each post as an "entry" — with a title (p-name), content (e-content), publish date (dt-published), canonical URL (u-url), author, and categories.

The beauty of it is that, again, it's just class names added to elements that were already there. The <article> got h-entry, the <h1> got p-name, the body got e-content. The post's tags became p-category. The author sits embedded as a hidden p-author h-card, reusing the same photo from my card.

The one technical thing that needed care was the date. dt-published wants an ISO 8601 format with a timezone, and the theme only showed day/month. The fix was to split the two apart: the datetime attribute holds the full machine-readable date, while the text on screen stays exactly as it was. The reader sees "05 Jun"; the parser sees 2026-06-05T23:51:21-03:00. Zero visual change.

I marked up both the individual posts and the home listing — because IndieWeb feed readers consume both.

Receiving conversations: webmentions with no server

This is where it gets interesting. Webmention is the open web's way of doing what likes and comments do inside social networks — except between independent sites. When someone replies to or mentions one of my posts from their own site, I get notified, and it can show up as a comment here.

The catch: receiving a webmention, in theory, needs a server to validate and store each notification. I didn't want to run that. The way out was webmention.io, a free hosted service that does all the heavy lifting. On my end, all it took was announcing its address with a single invisible line in the <head>:

<link rel="webmention" href="https://webmention.io/yourdomain.com/webmention">

That's it. From there on, any mention of my site gets received, verified, and archived by them. One elegant detail: logging into webmention.io happens through my own domain, using exactly the rel="me" links I'd already added. The pieces clicked together.

(I made a deliberate choice not to enable legacy pingbacks — they pull in way too much spam.)

Displaying it my way

Receiving is half the story; I wanted to show these conversations — but in my own style, not in some generic third-party widget fighting the blog's look.

I went with a small bit of my own JavaScript. It reads webmention.io's public API by the post's URL (no key needed), splits reactions (likes, reposts) from replies, and builds the markup by hand. Reactions become a compact row of avatars; replies become a clean list with the same monospaced date and the same dotted line I use on the home page. The commenter's name lights up in the brand color on hover.

And the part that mattered most for the aesthetic: if there are no mentions, the section just doesn't exist. No "0 comments," no empty box. Silence, the way it should be on a minimalist blog.

This is where I closed a funny little loop: I'd turned off Ghost's native comments because I didn't want "discussion" locked inside a membership platform. Webmentions are the open version of that — the conversation happens across the whole web and only echoes here.

What still lives outside

Technical honesty: one half of webmention — sending — still isn't inside the theme, and it doesn't need to be. When I link to another IndieWeb site, I have to fire off a notification to it. That's handled by an external service (like Bridgy or webmention.app) that watches my RSS feed and sends them automatically. The theme only needs solid h-entry markup — which is already there — for those notifications to go out correctly formed.

So: the "passive" half (being an identity, having readable posts, receiving and displaying conversations) all lives in the theme, invisible or understated. The "active" half (reaching out to notify other sites) is a service wired to RSS, no code.

In the end, none of this changes how the blog looks. A distracted visitor won't notice a thing. But underneath, the site stopped being an island. It now introduces itself, proves who it is, marks up its writing in a way any open tool understands, and is ready to receive conversations from any corner of the web — without leaning on Ghost for it, without leaning on any social network.

Ghost is still Ghost. I just taught it a second language — the one the open web speaks — and did it without Ghost (or the reader) ever noticing.