- Oct 9, 2026
- 4 min read
Share Cards as Code: Rendering og:images with Playwright (JPEG, Not WebP)
After checking the share images properly, I needed real ones: a card for this site, one for the consulting page, one for the ebooks library, and one per book.
I didn’t want to draw eleven images by hand and redraw them whenever a headline changes, so they’re rendered from HTML.
One script, every card
The cards are HTML and CSS, screenshotted at 1200×630 by headless Chromium:
const cards = [
['www/default.jpg', wwwHtml('Practical engineering for <em>data, AI and self-hosting</em>')],
['consulting/og-image.jpg', conHtml],
['ebooks/default.jpg', ebDefault()],
...books.map((b) => [`ebooks/${b.slug}.jpg`, ebBook(b)]),
];
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
for (const [name, html] of cards) {
fs.writeFileSync(tmp, `<!doctype html><meta charset="utf-8">${html}`);
await page.goto('file://' + tmp); // file:// so local fonts and covers load
await page.evaluate(() => document.fonts.ready);
await page.waitForLoadState('networkidle');
await page.screenshot({ path: path.join(out, name), type: 'jpeg', quality: 85 });
}
Things that made it work well:
- Each site’s real fonts. The templates load the same Fontsource files
the sites ship (
@font-facepointing into each site’snode_modules), so a card looks like its site. Waiting fordocument.fonts.readybefore the screenshot matters; without it you can capture the fallback font. - Book cards come from data. The script reads every book’s
_metadata.json(title, genre, chapter count, cover) and puts the cover on the left and the title on the right. A new book is one re-run. - The copy matches the page. The consulting card uses the hero’s headline in the same Playfair italic, so the preview and the landing page look like one thing.
white-space: nowrapon compound words. My first ebooks card broke “self-hosting” across two lines at the hyphen.
JPEG, not WebP
WebP is smaller, and it’s what Astro produces for the images on my pages.
For og:image it’s the wrong choice. The platform that matters most for
my work, LinkedIn, skips WebP share images and shows a post with no
picture. Facebook, WhatsApp, X, Slack and Discord do display them, but one
missing platform is enough.
The size argument doesn’t hold up either: a 1200×630 card is mostly flat colour and text, and as JPEG at quality 85 these come out at 45–80 KB, well under WhatsApp’s ~300 KB preview limit.
So: WebP on the page, JPEG (or PNG) in og:image.
Wiring: a default, with overrides
Each site has one default card, and pages can bring their own.
This site. The layout uses the default unless the page passes an image, and articles got an optional frontmatter field:
// content.config.ts
schema: z.object({
title: z.string(),
description: z.string().optional().default(""),
date: z.coerce.date().optional(),
// Share image (og:image): 1200×630 JPEG or PNG under public/,
// e.g. "/og/my-article.jpg". Without it the site card is used.
image: z.string().optional(),
}),
---
const socialImage = new URL("/og/default.jpg", Astro.site).href;
const pageImage = seo?.image ? new URL(seo.image, Astro.site).toString() : socialImage;
---
<meta property="og:image" content={pageImage} />
<meta property="og:image:alt" content={pageTitle} />
{pageImage === socialImage && <meta property="og:image:width" content="1200" />}
{pageImage === socialImage && <meta property="og:image:height" content="630" />}
og:image:width and og:image:height are only declared when they’re known
to be true, which they are for the default card.
The ebooks. Book and chapter pages used the portrait cover, which gets cropped to a strip in a landscape preview. Now they use the book’s card when one exists, and the site card otherwise:
---
import { existsSync } from 'node:fs';
const shareImage = existsSync(`public/og/${slug}.jpg`) ? `/og/${slug}.jpg` : undefined;
---
<BaseLayout title={title} description={description} ogImage={shareImage}>
Checking it
The audit’s new share-image check now reports, for all three sites:
{"format":"jpeg","width":1200,"height":630,"bytes":61274}
To preview a card as it will appear, the platforms’ own tools are the final word: LinkedIn’s Post Inspector and Facebook’s Sharing Debugger fetch the page fresh and show the card. They also refresh the platform’s cache after you change an image.
Takeaways
- Render share cards from HTML. Same fonts, same colours, one command after a copy change.
- JPEG or PNG for
og:image. Save WebP for images on the page. - One default per site, overrides per page. A missing card falls back to something that looks deliberate.