I found the bug by sharing a link. Dropped the NAPA SEASON album page into a WhatsApp chat and watched it render as a blank white card. No image, no colour, just the title and a grey rectangle where the preview should be.

The reflex was to assume the album page was misconfigured. It wasn’t. The album page was the messenger. Every route on the site that was not a blog post had the same blank preview, and I had never noticed, because the one thing you almost never see is your own link previews.

Why the blog looked fine

The writing posts set their own share image. Each post’s generateMetadata reads the hero from frontmatter and emits an og:image pointing at it. So every time I shared an article, the preview looked correct, and I carried on believing the site had share images sorted.

It didn’t. It had share images on exactly one route type. The root layout’s openGraph config had no images field at all:

openGraph: {
  title: "Captain Random",
  description: "Software, writing, and tinkering in public.",
  // no images
}

With nothing there, every route that didn’t override it (the homepage, /creative, /learning, every album and index page) emitted no og:image tag. WhatsApp, iMessage, and the rest had nothing to render, so they rendered nothing. The blog override was a spotlight on the one working surface, and it hid the dark everywhere else.

The two-part fix

A share-image system needs a floor and a ceiling. The floor is a site-wide default that catches every route. The ceiling is a per-content override for the pages that deserve their own art.

The floor: a single default card at public/images/og-default.png, 1200×630, the logo centred on the --void background. I built it by padding the logo onto a solid canvas at the exact share dimensions:

sips --padToHeightWidth 630 1200 --padColor 0A0908 logo.png --out og-default.png

--padToHeightWidth takes height then width, and pads onto a solid colour without needing ImageMagick. That went into the root layout’s openGraph.images plus a twitter: { card: "summary_large_image" } default. Now the homepage and every un-overridden route emit a real, branded preview.

The ceiling: the album pages already had a cover field on their frontmatter type, unused. I wired it into the album generateMetadata so a cover overrides the default. NAPA SEASON now shares with its actual cover art rather than the fallback logo.

One thing that could have bitten and didn’t: metadataBase was already set, so the relative image paths resolve to absolute URLs in the emitted tags. Without it, the paths ship as relative and every scraper ignores them. Worth checking first, because it fails silently in exactly the same way the missing default did.

The rule this became

The lesson generalised past this one site. A per-content override without a base default is not a share-image system. It is a share-image system for one route, and it will pass every check you run on that route while the rest of the site previews blank.

So the standard now, on every web project I ship: a site-wide default og:image at 1200×630 plus a summary_large_image twitter card, and per-content covers layered on top for the pages that earn them. The verification is deterministic and takes a minute: build, grep the emitted HTML for og:image, confirm the referenced file returns HTTP 200. No route may preview blank.

I shipped this site for weeks believing the previews were fine, on the evidence of the one surface I ever shared. The fix was an afternoon. Not noticing was the expensive part.

All writing