Vibe coded the post to Mastodon
I have vibe coded this publish to Mastodon, after spending time with Ghost and moving my domain around. So much fun! On top of that I have been doing all kinds of other things on my home lab, I have most of my music now all categorized and in Navidrome. I am sure there are other projects I have done, or tried and gave up on (like a site visitor counter to email me every morning, as that will be one feature I will miss from WordPress). I am however feeling rather accomplished! I even added a new image to hopefully replace that purple shaded box underneath my Mastodon posts! I am going to try and figure out how to put this vibe coded transformation onto my side bar but if I cannot figure it out please find the instructions below. Sorry for the titles those should be comments with the # before them.
Using Mastodon as a Comment System for Ghost (No Membership Signup Required)
Ghost has a native commenting system, but it requires readers to create a
member account on your specific site before they can comment — not
everyone wants that friction, and I didn't either.
Since I already post to Mastodon and use it actively, I built a system
that uses Mastodon replies as my blog's comments instead. No account on
my site required — just an existing (or brand new, free) Mastodon
account, which a lot of readers already have.
Here's the full system, in case it's useful to anyone else running Ghost.
The high-level idea
- When I publish a new post, a small service automatically posts it to
my Mastodon account. - That same service writes the resulting Mastodon post's ID back into
the specific blog post it came from. - A small script on each post page reads that ID, fetches any replies
to that Mastodon post, and displays them as comments — with a link
inviting readers to reply on Mastodon to join the conversation.
Three moving pieces, each fairly small on its own.
Piece 1: Auto-posting to Mastodon on publish
This is a tiny Node.js/Express service that listens for a webhook from
Ghost and posts to Mastodon when it fires.
In Ghost: Settings → Integrations → Add custom integration → give it
a name → scroll to Webhooks → Add webhook, with:
- Event:
Post published - Target URL: wherever this service is reachable (e.g.
http://your-server:3010/webhook)
The service itself (server.js):
const express = require('express');
const app = express();
app.use(express.json({ limit: '10mb' }));
const MASTODON_INSTANCE = process.env.MASTODON_INSTANCE;
const MASTODON_ACCESS_TOKEN = process.env.MASTODON_ACCESS_TOKEN;
const EXCERPT_LENGTH = 250;
function stripHtml(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
function buildExcerpt(post) {
let text = post.excerpt && post.excerpt.trim().length > 0
? post.excerpt.trim()
: stripHtml(post.html || '');
if (text.length > EXCERPT_LENGTH) {
text = text.slice(0, EXCERPT_LENGTH).trim() + '...';
}
return text;
}
app.post('/webhook', async (req, res) => {
try {
const post = req.body.post?.current;
if (!post) return res.status(400).send('No post data');
const excerpt = buildExcerpt(post);
const statusText = `${post.title}\n\n${excerpt}\n\nRead more: ${post.url}`;
const mastoResponse = await fetch(`${MASTODON_INSTANCE}/api/v1/statuses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${MASTODON_ACCESS_TOKEN}`
},
body: JSON.stringify({ status: statusText })
});
const mastoData = await mastoResponse.json();
console.log('Mastodon response:', mastoResponse.status, mastoData.url || mastoData.error);
res.status(200).send('OK');
} catch (err) {
console.error('Error in webhook handler:', err);
res.status(500).send('Error');
}
});
app.listen(3000, () => console.log('Webhook receiver listening on port 3000'));
You'll need a Mastodon access token — create it under Settings →
Development on your Mastodon instance, with just the write:statuses
permission checked.
Piece 2: Linking each post back to its Mastodon thread
This is where it gets more useful. Rather than just posting to
Mastodon, the service also writes the resulting post's ID back into
the Ghost post itself, so the page knows which thread to pull replies
from later.
This uses Ghost's official Admin API library
(@tryghost/admin-api), which handles the JWT authentication for you —
worth using this rather than hand-rolling it, since a common mistake
(passing the API secret as a raw string instead of hex-decoding it
first) produces a signature that looks valid but silently fails.
const express = require('express');
const GhostAdminAPI = require('@tryghost/admin-api');
const app = express();
app.use(express.json({ limit: '10mb' }));
const MASTODON_INSTANCE = process.env.MASTODON_INSTANCE;
const MASTODON_ACCESS_TOKEN = process.env.MASTODON_ACCESS_TOKEN;
const EXCERPT_LENGTH = 250;
const ghostApi = new GhostAdminAPI({
url: process.env.GHOST_URL,
key: process.env.GHOST_ADMIN_API_KEY,
version: 'v5.0'
});
function stripHtml(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
function buildExcerpt(post) {
let text = post.excerpt && post.excerpt.trim().length > 0
? post.excerpt.trim()
: stripHtml(post.html || '');
if (text.length > EXCERPT_LENGTH) {
text = text.slice(0, EXCERPT_LENGTH).trim() + '...';
}
return text;
}
app.post('/webhook', async (req, res) => {
try {
const post = req.body.post?.current;
if (!post) return res.status(400).send('No post data');
const excerpt = buildExcerpt(post);
const statusText = `${post.title}\n\n${excerpt}\n\nRead more: ${post.url}`;
const mastoResponse = await fetch(`${MASTODON_INSTANCE}/api/v1/statuses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${MASTODON_ACCESS_TOKEN}`
},
body: JSON.stringify({ status: statusText })
});
const mastoData = await mastoResponse.json();
console.log('Mastodon response:', mastoResponse.status, mastoData.url || mastoData.error);
if (mastoResponse.status === 200 && mastoData.id) {
try {
await ghostApi.posts.edit({
id: post.id,
updated_at: post.updated_at,
codeinjection_foot: `<script>window.mastodonPostId = "${mastoData.id}";</script>`
});
console.log('Wrote Mastodon post ID back to Ghost post:', post.id);
} catch (ghostErr) {
console.error('Failed to update Ghost post with Mastodon ID:', ghostErr);
}
} else {
console.log('Skipped Ghost update — condition not met. Status:', mastoResponse.status, 'ID present:', !!mastoData.id);
}
res.status(200).send('OK');
} catch (err) {
console.error('Error in webhook handler:', err);
res.status(500).send('Error');
}
});
app.listen(3000, () => console.log('Webhook receiver listening on port 3000'));
You'll need a Ghost Admin API key too — same Integrations screen,
scroll up from the webhooks section. It comes as id:secret, both
needed as-is (the library handles the encoding).
A note on post dates: this edit does not change your post's
published date. Ghost does separately track a "last updated" timestamp
that any edit refreshes — whether that's visibly shown to readers
depends entirely on your theme.
Piece 3: Displaying the replies as comments
A script that reads the ID set in Piece 2, fetches replies from
Mastodon's public API, and renders them into the page. This lives
entirely in Ghost's Code Injection (Settings → Code injection — either
the Site Header or Footer both work, since the script waits for the
page to finish loading either way) — no separate file needed, since
Ghost doesn't have a clean, reliable way to serve arbitrary static files
on its own.
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof window.mastodonPostId === 'undefined') return;
const MASTODON_INSTANCE = 'https://your-instance.example';
const postId = window.mastodonPostId;
fetch(`${MASTODON_INSTANCE}/api/v1/statuses/${postId}/context`)
.then(res => res.json())
.then(data => {
const replies = data.descendants || [];
const container = document.createElement('div');
container.className = 'mastodon-comments';
const heading = document.createElement('h3');
heading.textContent = replies.length > 0
? `${replies.length} comment${replies.length === 1 ? '' : 's'} via Mastodon`
: 'Comments via Mastodon';
container.appendChild(heading);
const link = document.createElement('a');
link.href = `${MASTODON_INSTANCE}/@yourhandle/${postId}`;
link.target = '_blank';
link.rel = 'noopener';
link.textContent = 'Reply on Mastodon to join the conversation';
link.style.display = 'block';
link.style.marginBottom = '20px';
container.appendChild(link);
replies.forEach(reply => {
const item = document.createElement('div');
item.className = 'mastodon-comment';
item.style.borderTop = '1px solid #ddd';
item.style.padding = '12px 0';
const author = document.createElement('strong');
author.textContent = reply.account.display_name || reply.account.username;
item.appendChild(author);
const content = document.createElement('div');
content.innerHTML = reply.content;
item.appendChild(content);
container.appendChild(item);
});
const target = document.querySelector('.gh-content') || document.querySelector('article') || document.body;
target.appendChild(container);
})
.catch(err => console.error('Failed to load Mastodon comments:', err));
});
</script>
Backfilling old posts (without spamming your followers)
If you're adding this to a blog that already has a back catalog, you'll
want existing posts to have comment threads too — but posting all of
them to your main account at once would flood your followers' feeds
with a sudden burst of old content.
The fix: run the backfill from a separate, dedicated Mastodon
account with no real followers, rather than your main one. New posts
going forward still go through your main account exactly as normal;
only the retroactive backfill uses the second account.
const GhostAdminAPI = require('@tryghost/admin-api');
const MASTODON_INSTANCE = 'https://your-instance.example';
const BACKFILL_ACCESS_TOKEN = process.env.BACKFILL_ACCESS_TOKEN;
const ghostApi = new GhostAdminAPI({
url: process.env.GHOST_URL,
key: process.env.GHOST_ADMIN_API_KEY,
version: 'v5.0'
});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function backfill() {
let page = 1;
let hasMore = true;
while (hasMore) {
const posts = await ghostApi.posts.browse({ page, limit: 15, fields: 'id,title,url,updated_at,codeinjection_foot' });
for (const post of posts) {
if (post.codeinjection_foot && post.codeinjection_foot.includes('mastodonPostId')) {
console.log(`Skipping "${post.title}" — already linked`);
continue;
}
const statusText = `${post.title}\n\n${post.url}`;
try {
const mastoResponse = await fetch(`${MASTODON_INSTANCE}/api/v1/statuses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BACKFILL_ACCESS_TOKEN}`
},
body: JSON.stringify({ status: statusText, visibility: 'unlisted' })
});
const mastoData = await mastoResponse.json();
if (mastoResponse.status === 200 && mastoData.id) {
await ghostApi.posts.edit({
id: post.id,
updated_at: post.updated_at,
codeinjection_foot: `<script>window.mastodonPostId = "${mastoData.id}";</script>`
});
console.log(`Linked "${post.title}" -> ${mastoData.url}`);
} else {
console.error(`Failed to post "${post.title}":`, mastoData.error);
}
} catch (err) {
console.error(`Error processing "${post.title}":`, err);
}
await sleep(3000);
}
hasMore = posts.meta.pagination.pages > page;
page++;
}
console.log('Backfill complete.');
}
backfill();
Note the skip if already linked check at the top of the loop — this
makes it safe to re-run if it fails partway through, and thevisibility: 'unlisted' keeps these backfill posts out of public
timelines (though worth knowing: Unlisted still shows up in a
follower's home feed exactly like a public post — the dedicated
account with no followers is what actually solves the spam problem, not
the visibility setting alone).
I'd recommend testing against a single post first before running it
against your whole history — same script, just pointed at one specific
post by its slug via ghostApi.posts.read({ slug: 'your-post-slug' })
instead of the full browse loop.
Known limitations
Mastodon will show a link preview card under every post, and you
can't disable or resize it. This is a genuine, current Mastodon
limitation, not something specific to this setup — there are open,
unresolved feature requests asking for exactly this. What you can
control is which image shows in that card: it pulls from your blog'sog:image tag, so setting a custom cover image in your CMS's settings
(rather than leaving whatever generic default it ships with) changes
what appears there.
Replies only show up on posts made after this was set up, unless
you run the backfill for older content.
This depends on Mastodon's API remaining stable — like any
integration built against a third-party API, future changes on their
end could require adjustments here.
Why this approach
I didn't want to require readers to create an account just to leave a
comment, and I didn't want a third-party embedded comment widget either
(most come with tracking, ads, or a company that could shut down or get
acquired). This keeps commenting genuinely decentralized — replies live
on Mastodon itself, under the commenter's own control, not locked into
my specific site.