In August the link previews of the MusicTech Lab website turned out to be broken. Shared on LinkedIn or X, a page came out bare: a title, a URL, no picture. When I counted, 90 of 116 pages shared with no image at all, and the other 26 all showed the same generic card.
Two separate bugs, and neither one failed the build.
What was broken
| Problem | Pages | Why nobody noticed |
|---|---|---|
Site-wide og:image was a WebP | 90 | The page looks fine. LinkedIn and X just do not render WebP previews. |
defineOgImageComponent() called without props | 26 | Every card rendered, all with the same default copy. |
| Post photos are WebP | all cards | Satori drops WebP with no error. The photo panel stays empty. |
The site is Nuxt 4 with Nuxt Content, prerendered to about 1,400 static routes on Firebase Hosting. Cards come from nuxt-og-image v6, which renders a Vue component to PNG with Satori at build time.
One description, two outputs
The first fix was boring on purpose. Pages used to set their meta in one place and their card in another, so the two drifted apart. Now every page calls one composable, useMtlSeo(), and the card is built from the same title and description as the meta tags.
It does a few small things that matter on a 1200x630 card:
- cuts the title at 80 characters and the description at 150, on a word boundary, because Satori keeps drawing past the bottom edge
- strips the "| MusicTech Lab" suffix, since the card already carries the logo
- picks a small label from the URL section, like "Blog" or "Services"
Satori and WebP
Nearly every photo on the site is WebP, and Satori decodes only PNG and JPEG. So a prebuild script, generate-og-photos.mjs, reads the image: field of every post and writes a JPEG twin next to it as <name>.og.jpg: 912 px wide (twice the 456 px panel, for retina previews), quality 80. The twins are gitignored and regenerated on every build, so a new post is covered just by having a photo in its frontmatter.
Satori does not throw on an image format it cannot read. You get a card with an empty space where the photo should be. Check a real card, not the build log.
The build refuses a bad card
The part I like most is the guard. check-og-images.mjs runs as postbuild, reads the prerendered HTML in .output/public, so it sees exactly what a scraper sees, and fails the build if any shareable page has:
if (!image) problems.push(`${page}: no og:image`)
if (!image.startsWith('http')) problems.push(`${page}: og:image is relative`)
if (/\.(?:webp|svg)(?:\?|$)/i.test(image)) problems.push(`${page}: LinkedIn and X do not render this`)
// and: one card used by more than 25% of pages means pages stopped passing their own title
Pages with nothing to share are skipped and counted: the 404 shell, anything marked noindex, and redirect stubs with no <title>.
flowchart LR
A["prebuild<br/>JPEG twins of post photos"] --> B["nuxt generate<br/>Satori renders 1200x630 PNG per page"]
B --> C["postbuild<br/>check-og-images.mjs"]
C -->|all good| D[Deploy]
C -->|"missing, relative, WebP or duplicate"| E[Build fails]
Small settings that matter
The module default is 1200x600. LinkedIn, X and Slack crop against 1.91:1, so every card was slightly off.
Without it, names like Smenżyk and Kraków rendered as blank boxes on the card.
Satori shares the prerender worker pool, so one card can queue well past the 15 s default.
v5 broke on Nuxt 4.4: all 600+ card prerenders failed with HTTP 400. v6 wants a renderer suffix, MusicTechLab.satori.vue.
After the v6 upgrade the build prerendered 1,376 routes and 152 cards, and it needed the Node heap raised from 4 GB to 6 GB to get through the prerender teardown.
What it took
Two commits, one in July to get nuxt-og-image v6 building at all and one in August for the per-page cards and the check. The rendering was the easy part. The real fix was moving the knowledge "LinkedIn ignores WebP" and "Satori ignores WebP" out of my head and into a script that breaks the build. Next time I would write the check first, before touching the card design, because it would have shown me the 90 bare pages in one run.

Related notes
1,200 Looms Later: How Async Video Became My Development Superpower
I've recorded 1,212 Loom videos over 5 years. Here's how async video transformed the way I demo, communicate with clients, and document decisions as a founder and developer.
A swim tech coaching course I finished on a plane
I took the SwimTech Coach (STC) Certification on Udemy during a flight. It promises data-driven swim coaching with wearables. It stays very basic and repeats itself, and I learned nothing new.



