Last week lans.cloud launched on Product Hunt, and I shared blog posts in a few places to go with it. That's when I noticed something embarrassing: pasting a post link into Slack or X produced… a bare URL. No title, no description, no image. And searching Google for posts I'd published months ago found nothing at all.
So I did what I should have done at the start: a proper SEO audit of my own blog. The results were humbling. This is the write-up — what was broken, why it matters, and the fixes, including my favorite part: the blog now draws a branded social card for every post, server-side, on demand.
The audit: how bad was it?
The blog is a self-hosted Astro app in SSR mode behind Traefik, reading markdown folders from disk. The rendering was fine — clean semantic HTML, fast, no client-side rendering to hide content from crawlers. But everything around the content was missing:
- No sitemap and no robots.txt. Google had no inventory of my posts and no instructions about what to skip.
- No canonical URLs. I hadn't even set
sitein the Astro config, so the app couldn't compute an absolute URL for itself. - Zero social metadata. No Open Graph tags, no Twitter card, no image. Hence the bare-URL previews.
- No structured data. Google had no
BlogPostingorWebSiteJSON-LD to work with. - No RSS feed — and technical blogs still get real traffic from feed readers.
- Not a single icon. The
public/folder contained one CSS file. Every visit 404'd on/favicon.ico. - Dead URLs never died. A removed or mistyped post URL returned a
302redirect to a/404route… which didn't exist. Crawlers learn from this that every dead URL on your site is a live page. These "soft 404s" poison an index — deleted posts never drop out. - The site name in every title was literally the word "Blog". Every tab read like
Home | Blog. - And a subtle one I only caught by reading my own HTML output: every post had two
<h1>s — the template rendered the title as anh1, and the markdown body started with the same# Titleagain.
Individually each of these is minor. Together they meant search engines and social platforms treated the blog as a ghost.
The fixes
Canonical URLs, OG tags, JSON-LD
The layout component now computes a canonical URL for every page (query strings stripped, so /?auth=required variants don't register as separate pages) and emits the full social set: og:type/og:title/og:image with dimensions, Twitter card, article:published_time and article:tag on posts, and JSON-LD — WebSite on the homepage, BlogPosting on posts. Gated pages (drafts, search, transactional pages) get noindex.
One detail worth stealing: my premium posts used to redirect crawlers away entirely. Now they render a public teaser (title + excerpt + subscribe button) with Google's official paywall markup, so they can be indexed without the full text ever appearing in the teaser HTML:
{
"@type": "BlogPosting",
"isAccessibleForFree": false,
"hasPart": {
"@type": "WebPageElement",
"isAccessibleForFree": false,
"cssSelector": ".paywalled-content"
}
}
A sitemap that can't go stale
My posts live on a mounted volume and appear without a rebuild, so a build-time sitemap would lie within a day. Instead, /sitemap.xml is a tiny SSR endpoint that walks the current post inventory per request:
export const GET: APIRoute = async ({ site }) => {
const posts = await getAllPosts();
const entries = [
{ loc: site.href, lastmod: posts[0]?.date, priority: '1.0' },
...posts.map(p => ({ loc: new URL(p.slug, site).href, lastmod: p.date, priority: '0.8' })),
// …tag and category archives
];
return new Response(renderXml(entries), {
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
});
};
Same approach for /rss.xml. Publish a post, and both are already correct.
Real 404s
The fix for the soft-404 chain was two lines of Astro: a real 404.astro page that sets Astro.response.status = 404, and return Astro.rewrite('/404') wherever a post lookup fails. Rewrite — not redirect — is the key: the visitor gets a helpful page, and the crawler gets an honest 404 status on the dead URL itself. Astro's route priority does the rest, since a static /404 route beats the [...slug] catch-all.
Icons drawn by code
The blog had no logo, and the server had no ImageMagick, no sharp, no image library at all. So the icon set is generated by a ~200-line Node script with zero dependencies: a minimal PNG encoder (the format is just zlib-compressed scanlines plus CRC32 chunks, both available in node:zlib and thirty lines of table code) and a tiny signed-distance-field rasterizer for the shapes — a rounded rectangle, two round-capped strokes for the "L", a dot. Antialiasing falls out of the math for free: coverage is just the distance to the shape edge clamped to a pixel.
One script produces the favicon, the touch icons, the web manifest set, and the default social card, all pixel-identical to the SVG favicon. When the logo changes, I rerun it.
The part I'm actually excited about: per-post social cards
A default og-image is fine, but the good stuff is a card with the post's own title on it. Rendering text needs real font shaping though, which is where satori comes in — it turns a JSX-ish tree plus font files into SVG, and resvg rasterizes that to PNG. Both run happily inside my Alpine container.
So now there's an endpoint: /og/<post-slug> renders a 1200×630 card on demand — dark field, logo, the title in Inter ExtraBold with a size that adapts to title length, date and category in the footer — and caches it in memory. Every post's og:image points at its own card. Here's a live one.
The one gotcha: satori needs actual font files (TTF), not the woff2 your CSS uses. Since I was self-hosting the Inter variable font anyway (that removed the Google Fonts round-trip and let me tighten the CSP to first-party only), the TTF statics ride along in the image.
Things that went wrong, honestly
Google said it couldn't fetch the sitemap. After submitting in Search Console I got the dreaded "Couldn't fetch". I verified everything — status, content type, encoding, even fetching with Googlebot's user agent. All perfect. It was nothing: Search Console routinely shows "Couldn't fetch" until the first real crawl happens, hours or days later. If your sitemap curls clean, the fix is patience (and double-checking you submitted the full URL if you're on a domain property).
The Docker build broke twice on npm ci. My host runs npm 11; node:22-alpine ships npm 10, which rejects npm-11 lockfiles over platform-optional dependencies ("Missing: X from lock file"). The durable fix was one Dockerfile line: RUN npm install -g npm@11 in the build stage, so host and image agree forever.
A deploy exposed a silent, older bug. My SQLite volume files predated the container's USER node hardening and were still owned by root — meaning database writes had been quietly failing for days. A schema change made it loudly fatal at boot, which is how I finally noticed. chown -R node:node on the volume, and a lesson learned: when you drop container privileges, audit the volumes that were created before you did.
Results
- Shared links now unfurl with a branded title card everywhere.
- Sitemap submitted and fetched; posts are entering the index.
- Dead URLs return real 404s, so the index can self-clean.
- Zero new client-side JavaScript — every fix is server-side or static.
- Lighthouse's SEO score went from "why bother" to green across the board.
Lessons
- Audit your own HTML output, not your templates. The duplicate
<h1>and markdown syntax leaking into meta descriptions were only visible in the rendered page source. - SSR blogs want SSR sitemaps. If content appears without a rebuild, anything generated at build time is already wrong.
- Soft 404s are the quiet killer. If dead URLs redirect somewhere friendly, crawlers never forget them. Return the honest status code.
- You need less tooling than you think. A favicon pipeline is zlib and math; a social-card renderer is two small libraries and a font file.
The same week also brought a self-hosted newsletter (double opt-in, through my own mail gateway — no third-party list provider) and cookie-free analytics with Umami. Both deserve their own write-up; if you want that one when it lands, well — there's now a signup form right below this post.