I shared the NAPA album link on WhatsApp. The card came back blank — no title, no image, no description. Just the URL, rendered as a grey rectangle.
The album page wasn’t missing an og:image because I’d neglected it. It was missing because the site’s root layout had no default image. I’d never looked at what a share card looked like for anything except writing posts. Writing posts worked. That was the test I’d run. The test I hadn’t run was: share every other kind of page and look at what the card shows.
The gap the setup created
Writing posts on captainrandom.co.uk have always had og:image. Each post carries a hero image in its frontmatter; the post layout reads it and sets metadata.openGraph.images accordingly. Every time I’d tested sharing, I’d shared a post. Posts looked correct. I inferred the site was correct.
The root layout’s openGraph object had a title, a description, a type. No images key. No default. No fallback. Every route that didn’t set its own og:image — the homepage, /about, /work, /learning, album pages, the creative index — emitted HTML with no <meta property="og:image"> tag. Blank on every platform that renders card previews.
There’s a second dependency worth naming. Next.js’s Metadata API requires metadataBase to be set in the root layout for og:image URLs to resolve as absolute. Crawlers need a full URL to fetch the image; relative paths silently fail. Without metadataBase, a correctly-wired images array still produces a broken card.
Why the build didn’t catch it
TypeScript has no type for “social sharing is broken.”
The Next.js Metadata type accepts openGraph without images. That’s a valid configuration — some sites deliberately omit og:image. tsc cannot distinguish deliberate omission from forgetting. next build produces valid HTML either way. ESLint has no og:image rule. Lighthouse audits performance, accessibility, and PWA readiness, not whether a share card will render. The HTML is well-formed. The pipeline has nothing to flag.
The coverage gap is structural. The build pipeline checks whether code is correct. Correct TypeScript, valid HTML, clean build output. None of those checks test whether the site behaves correctly when a real person does a real thing with it. Sharing a link is not a build step. It’s a user action on a platform you don’t control, rendering output you won’t see unless you do it yourself.
This is the same class of problem as a missing canonical tag or a misconfigured robots directive. The HTML is well-formed in all three cases. Whether the HTML does what you intend requires checking the external surface — the crawler, the platform renderer, the cache — which sits outside the build pipeline’s scope entirely.
The asymmetry is worth holding. The build pipeline is good at catching correctness failures: does the function compute the right thing, does the type match the contract, does the query return the expected rows. It has no mechanism for catching existence failures: is the thing that needs to be there actually there. The missing images key is an existence failure. The site works. The HTML is valid. The pipeline sees nothing wrong, because nothing is wrong in the domain the pipeline can inspect.
The fix
Two parts: one mandatory, one additive.
Site-wide default. An og-default.png — 1200×630, logo centred on the brand background colour — goes into the root layout’s openGraph.images and twitter.images. Every route that doesn’t set its own og:image inherits this. The card is no longer blank.
Build it with sips, the macOS image processor that ships with the OS:
sips --padToHeightWidth 630 1200 --padColor <hex> logo.png --out og-default.png
The flag takes height-then-width, which contradicts the parameter name but matches the man page. The result pads the source image onto a solid canvas. No ImageMagick, no third-party dependency.
Per-content overrides. Album pages now read their cover from frontmatter and pass it into metadata.openGraph.images at the route level. Writing posts already did this. The default is the floor; content pages with a meaningful hero image override it.
The order matters. Wire the default first, then layer per-page overrides on top. If you reverse it — adding route-specific overrides for the pages you’ve thought about and skipping the default — every unhandled route remains blank. Nothing in the pipeline will tell you.
The cache problem
Fixing the code isn’t the end of it.
WhatsApp, Facebook, and Twitter cache og:image data aggressively. A link shared before the fix is cached with a blank card. The fix goes live; the cached link still previews blank for anyone who shared it previously. Cache expiry varies by platform and isn’t reliably documented.
Facebook Sharing Debugger has an explicit re-scrape button. For the others, you wait or change the URL, which resets the cache key. If you’ve shared anything publicly during the period when the default was missing, that cache is poisoned for an indeterminate window.
Out of scope: forcing re-scrape on WhatsApp or Twitter without a URL change. There’s no API for it.
The diagnostic
Before shipping any route:
curl -s https://your-site.com/the-page | grep og:image
If that returns nothing, the page shares blank. Run it against at least three representative cases: the homepage (default-only), a post (content-specific override), and a non-post content page (should use the default, and now does). If any of the first or third group returns nothing, the default isn’t wired or metadataBase is missing.
After the fix, confirm the image is actually reachable:
curl -I https://your-site.com/og-default.png
HTTP 200 means it’s there. A 404 produces a blank card even when the HTML is correct — the crawler fetches the URL in the tag and gets nothing back.
The rule this enforces
Per-page og:image overrides are only worth anything if the site-wide default exists. Without the default, every route that doesn’t explicitly override is sharing blank. The routes you’ve tested look fine. The rest are invisible to you until someone shares one.
The captainrandom.co.uk pre-ship checklist now includes four items: default og:image wired into root layout, metadataBase set, og:image present on at minimum three representative routes as confirmed by grep, and default image reachable at HTTP 200. The one check the checklist can’t automate is the last one: share a real link into a real chat and look at the actual card. PR #258 in the captain_random repo is the seven-line fix. The checklist is what keeps this from recurring.



