From Invisible to Indexed: The SEO Overhaul This Blog Badly Needed

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 site in 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 BlogPosting or WebSite JSON-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 302 redirect to a /404 route… 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 an h1, and the markdown body started with the same # Title again.

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

  1. 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.
  2. SSR blogs want SSR sitemaps. If content appears without a rebuild, anything generated at build time is already wrong.
  3. Soft 404s are the quiet killer. If dead URLs redirect somewhere friendly, crawlers never forget them. Return the honest status code.
  4. 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.