My blog had no title, and nobody told me
What this is about. I wanted my blog posts to show up on LinkedIn, Bluesky, Mastodon and the rest without copy-pasting each one by hand. Before any of that, my AI found that my blog pages had no <title>, no description and no preview image. Every link I had ever shared was a naked URL.
Why. If you run your own CMS, the stuff a platform like WordPress gives you for free is stuff you have to remember yourself. Nobody reminds you, because the page looks fine in a browser. This post is how we found the hole, filled it across all my sites, and built a small posting tool that never posts unless I say so.
The ask
Here's what I typed, typos included:
Investigate the possibility and if viable, make a plan as how to publish (parts of) the posts into social media, like Linkedin, Instagram, Fb, tiktok(?) and others. Make a plan and rade according to difficulty and small money, if not free gains.
I expected a comparison of APIs. What I got first was a bug report about my own blog.
Step zero: curl your own page
The AI didn't start with LinkedIn's docs. It fetched one of my live posts and looked at the <head>. There was nothing in it. No <title>, no meta description, no Open Graph tags, no Twitter card. And no RSS feed either: /rss.xml, /feed and /rss.xml all answered 404.
So a link to my post pasted into LinkedIn, Slack or a chat app showed up as just the URL. No headline, no picture, no reason to click.
The annoying part: the CMS already stored all of it. Every page has fields for a meta title, a meta description and a preview image. The blog template simply never rendered them. One of my sites, Rebel Kayaks, had its own head component for trip-report share cards, written months ago. The other sites never got one, and I never noticed, because a browser tab with a URL in it looks perfectly normal when you're the one who typed the URL.
The ratings
The plan came back as a table, rated 1 (an hour) to 5 (days, plus approval processes). Abbreviated:
Channel | Difficulty | Cost | Worth it? |
|---|---|---|---|
Title, preview tags, RSS | 1–2 | free | Do first. Every share everywhere gets better, and so does search |
LinkedIn, personal profile | 2 | free | The main channel for my readers |
dev.to, whole post with a canonical link | 2 | free | Reaches developers, and Google still credits my site |
Bluesky, Mastodon | 1 | free | Cheapest APIs there are |
Facebook Page, Threads | 3 | free | Later. Small audience for posts about databases |
4 | free | Needs images, can't carry links | |
X | 2 | about $0.20 per post with a link | Pasting by hand is free |
TikTok | 5 | free | Skip for the blog. Maybe for the games |
I said "Yes, you may commence 1 through 3": the tags, a per-post "social kit", and one-command posting for the four easy platforms.
Step 1: give every post a head
The fix is one component that renders the fields the CMS already has: title, description, canonical URL, Open Graph and Twitter tags, the publish date and the post's tags. It went into the shared UI package, so every site gets it.
The fiddly part is the preview image. Most of my posts don't have one set, so it falls back:
the post's own preview image, if set
else the first image in the page's modules (hero, gallery and so on)
else the first
<img>in the post's text
SVGs are skipped at every step. Social cards don't render them, and I have a lot of SVG art, so without that rule half my previews would be broken pictures.
The RSS feed lists published posts only, never drafts or scheduled ones, whoever is logged in when it's fetched. A feed is the kind of thing that gets cached and mirrored by services you've never heard of, so it's the wrong place to leak a draft.
One small war story: my sites get shared files through a sync script that never overwrites a file a site already has. That's on purpose, because sites customise things. It also meant the new component had to be copied into each site by hand, and two of them have their own blog page that needed it wired in. A sync that's safe by default is also a sync that's quietly incomplete by default.
Step 2: the social kit
For each post, a script writes one file with a section per platform. My AI writes the text in my voice, cut only from the already-redacted post, and I edit it. A check command prints the final text for each platform and fails on anything too long.
Every platform counts differently, which is where the fun is:
LinkedIn allows 3,000 characters, but only the first ~210 show before "see more". The hook has to fit there.
Bluesky allows 300 graphemes, so an emoji counts as one.
Mastodon allows 500, and every link counts as 23 characters, however long it is. X does the same at 280.
Instagram can't carry a link at all.
Every link gets tracking tags (utm_source=linkedin and so on), so my own analytics tool, Muntra, shows which channel actually brings readers. The AI checked first that Muntra keeps those tags, because I had just made it strip other query strings for privacy.
Step 3: posting, but only when I say so
social-publish.mjs posts a kit to LinkedIn, Bluesky, Mastodon and dev.to (as a draft there). By default it's a dry run: it prints exactly what each platform would receive and sends nothing. Only --go posts, and the rule written into the repo is that --go runs when I say so, for that post. Never on the AI's own initiative, and never for several posts at once.
A few things I learned on the way:
Not every platform fetches your preview. Mastodon reads the page and builds the card itself. LinkedIn's and Bluesky's APIs don't: you have to hand them the title, the description and the image yourself. So the script reads my live page's preview tags and uploads the image. That's also why it refuses to post unless the live page answers 200 and has an og:title. Platforms cache previews for days, so a link shared one hour too early stays ugly for a week.
Bluesky counts in bytes. Links and hashtags in a Bluesky post are marked by byte position in the UTF-8 text, not by character. One "å" before a link shifts it by a byte, and the link lands one letter off. There's a test with Swedish characters for exactly that.
LinkedIn's text is a markup language. In the posts API a handful of characters, brackets and # among them, mean something. A plain bracket in your text has to be escaped, and a hashtag has to be written in LinkedIn's own little syntax to become a real hashtag.
My own server lies about images. The CMS serves .webp uploads as application/octet-stream. The script now looks at the file's first bytes to tell PNG, JPEG, WebP and GIF apart, instead of trusting the header. That's a fix for my script, not for the platforms' crawlers, so for preview images I stick to PNG or JPEG.
Logins live in the Keychain. No tokens in files, nothing in the chat. LinkedIn's token lasts 60 days, and status warns a week before it runs out.
What the AI got wrong
The script takes a file name, and optionally --to linkedin,bluesky. To find the file name it filtered out the argument after --to. When there was no --to, the "index after --to" was −1 + 1 = 0, so it filtered out argument number 0. Which was the file name. The dry run caught it before anything was posted, and that is the whole argument for dry runs in one line.
Also honest: the real API calls haven't been run yet, because none of my logins are stored yet. The unit tests, the Keychain storage and the live-page check are tested. The first real post per platform will go out one platform at a time, and I'll look at it on the platform before doing the next. The LinkedIn API version is pinned in one line, taken from vendor reports rather than LinkedIn's own docs, so if LinkedIn has retired it, the first post fails with an HTTP 426 and that line gets bumped.
What happened, how we fixed it, and how else it could be done
No title, description or preview tags on blog pages. Fixed with one shared head component that renders what the CMS already stored, with a sensible image fallback.
No RSS feed. Added one, published posts only.
Writing a post per platform by hand. A social kit per post, with length checks per platform.
Posting by hand to four places. One command, dry run by default, live-page check, a log so nothing posts twice, and only I say go.
Other ways to do it:
Option | Better at | Costs |
|---|---|---|
Our own scripts (this) | Free, fits my "I publish, not the bot" rule, text in my voice | We keep up with API changes ourselves |
Buffer | A calendar and analytics, they deal with API churn | Free for 3 channels, then about $5–6 per channel per month |
Postiz (open source, self-hosted) | Many platforms, free, my data | One more service to secure, back up and patch |
Zapier or IFTTT from the RSS feed | No code at all | Generic "New post: …" text that reads like spam |
A posting API like Ayrshare | One API for everything, including TikTok and Instagram | A monthly fee, and every post goes through a third party |
If you just want to post more, Buffer is the honest answer. I wanted the text in my voice and a hard stop before anything goes out, and that was easier to build than to configure.
Before you share a link from your own site
curlthe page and read the<head>. Is there a<title>? Anog:title? Anog:imagethat isn't an SVG?Paste the URL into LinkedIn's Post Inspector. It shows the card and refreshes the cached one.
Check that the image is served with a real image type, not
application/octet-stream.Make sure your RSS feed can't show a draft.
Share only after the page is live with its tags. The platforms remember the first version for a long time.
The first post to go out this way will be the one about my database being on the internet. If you see it on LinkedIn with a proper picture, it worked.