The og:image that wasn't there: blank WhatsApp previews and a pre-ship check no CI runs

The NAPA album link went to WhatsApp. The preview came back as a blank white card: no image, no title, no excerpt. The URL resolved. The page loaded in a browser. The card was still blank.

That was 7 July. captainrandom.co.uk had been live for months.

What the site actually had

Writing posts worked. Each post’s generateMetadata call sets images from the post’s coverImage frontmatter. The Next.js Metadata API outputs the og:image tag. Scrapers pick it up. The preview renders.

What the root layout did not have was an images field in its openGraph object. Title, description, URL, siteName, type were all present. Images were not. Every route that wasn’t a writing post emitted no og:image meta tag: the homepage, /about, /work, /learning/, albums. WhatsApp, iMessage, Twitter, LinkedIn: all blank card.

The gap had been there the whole time. No build check caught it, no lint rule flagged it, nothing in CI cared. The only thing that caught it was sharing a link on a phone.

What had shipped in the week before

In the week before the blank card appeared:

  • I ran an ultracode workflow across 16 parallel agents to author a navigation spec for the /learning/ hub
  • I shipped P2 of that navigation plan: hub track sections and JSON-LD structured data
  • I moved upcoming courses from a hard-coded PLACEHOLDER_TOPICS array to filesystem-driven _topic.mdx stubs

None of that is wasted work. JSON-LD helps search. Navigation helps users. Data-driven stubs are strictly better than hard-coded placeholders. All of it shipped clean.

And the site had no default share image for any route that wasn’t a blog post.

The ordering is wrong. Structured data for search before a basic og:image for share. If someone sends your URL on WhatsApp without JSON-LD, they get a link with text. Without an og:image, they get nothing. The blank card is the worse outcome.

Root cause

Next.js openGraph without images produces a page with no og:image tag. The Metadata API doesn’t warn, doesn’t default to anything, and outputs what you give it.

Scrapers follow the spec: no tag, no image. The blank card is correct behaviour from the scraper’s perspective. The fault is in the metadata.

Writing posts worked because post-level generateMetadata explicitly set images. The root layout had never set it, so the override worked and the fallback didn’t exist.

The fix

Two things: a site-wide default and per-content overrides.

Default. A 1200×630 og-default.png (logo on the site’s void background), built with:

sips --padToHeightWidth 630 1200 --padColor <hex> source.png --out og-default.png

sips argument order: height then width. It pads onto a solid canvas. No ImageMagick dependency.

The file goes into /public/. Root layout openGraph.images and twitter.images both reference it, with summary_large_image card type. Every route now has a fallback.

Per-content. Albums had a coverImage in frontmatter already. Wiring it into each album page’s generateMetadata follows the same pattern writing posts had always used. One function, one frontmatter field.

PR #258 on captain_random.

The cache problem

Fixing the metadata is the easy half. Social platforms cache og:image aggressively. A link shared before the fix went out renders the old blank card from cache. The platform doesn’t re-scrape on every view.

For Facebook and LinkedIn: the Sharing Debugger and Post Inspector force an immediate re-scrape. Run them for any link that was shared blank and matters.

For WhatsApp: no official tool. Re-scrape timing is opaque. Sharing the link in a fresh conversation usually forces it within a day or two.

For Twitter/X: no tool. New tweets see the new card. Old embeds stay cached.

Worth knowing before testing the fix by resharing the same link. The cache is what you’re seeing, not the live page.

Verification

After building:

grep -r 'og:image' out/

If any route has no match: that route has no og:image. The build passes either way.

After deploying, confirm the image is reachable:

curl -I https://captainrandom.co.uk/og-default.png

Expected: HTTP/2 200. Scrapers don’t fall back gracefully when the declared image URL 404s. Blank card again, different cause.

Both checks are two minutes. Neither was in the pre-ship process before 7 July.

The checklist change

SHARE is now a mandatory gate across every web project before any route goes live:

  • Root layout: openGraph.images set, 1200×630, metadataBase declared so relative URLs resolve absolute for scrapers
  • Content pages: frontmatter-driven og:image override in generateMetadata for posts, albums, products, profiles
  • Build verification: grep out/ for og:image on every route
  • Image reachability: HTTP 200 on the actual image URL, not assumed

Out of scope: re-scraping historical links. The cache will expire.

The gap no toolchain catches

Typecheck passed. Tests passed. Lighthouse didn’t flag it — share image coverage isn’t a Lighthouse audit. The gap sat in the metadata layer, inert until a real share on a real platform surfaced it.

This is the category of bug that no build process catches because build processes don’t know what a page is for. A blank card on WhatsApp is only a bug if link previews matter to you. The toolchain has no opinion on that. The human has to add the check.

The blank card on 7 July was the check. The pre-ship list is where it should have been.

All writing