When someone shares a link on WhatsApp, LinkedIn, X or Slack, the first thing people see isn’t the page: it’s its image. Until recently, every page on this site showed the same one, the brand’s general image. A service, a Lab demo and a post all looked the same in a chat.
Now every public page has its own, 1200 × 630, with the logo, the title, the description, the kind of page and the site’s domain (DEC-029, in Spanish). It’s generated at build time, as one more static file: it costs nothing at runtime, it doesn’t touch the CSP and it doesn’t change Lighthouse. Here’s how, and why the image comes from the built HTML and not from a list of pages.
The image comes from the HTML, not from a catalog
The obvious option was an og/[...ruta].png.ts endpoint with a catalog of pages. I ruled it out. The titles of the fixed pages live in their views, so the catalog would repeat them, and sooner or later an image would say something different from its page. Also, Astro’s collections don’t load in Vitest, so I couldn’t have tested it.
What I built instead is an Astro integration that runs when the build finishes. By then, Base.astro has already written each page’s og:* tags: title, description, language and URL. The integration walks the HTML files in dist/, reads those tags and puts together one card per page:
for (const file of await htmlFiles(out)) {
const head = readHead(await readFile(file, 'utf8'))
const page = head && cardFor(head)
if (page) cards.set(page.image, page.card)
}
So the image always says what the page says, in its language. A new service, project, demo or post gets its image without me touching any list, and if I change a title, the image changes on the next build.
Who decides which pages get their own image? Base.astro, with a short rule: every public page with an address of its own.
const ownImage = !isPrivate && hasCanonical
const ogImage = new URL(ownImage ? ogImagePath(Astro.url.pathname) : site.media.ogImage, Astro.site)
/admin/ and the 404 keep the general image. Each image’s path mirrors its page’s: /en/services/websites/ points to /og/en/services/websites.png, and the home, to /og/index.png.
What each card shows
Above the title there’s a small label with the kind of page: “Service”, “Project”, “Lab demo” or, on a post, “Post, 6 min read”. The image doesn’t work out the reading time: it reads it from twitter:label1 and twitter:data1, two tags the post page already writes so Slack and X show it in the preview. It’s the same text you see under the post’s title. And the post’s structured data (BlogPosting) uses its own image, not the general one.
The design lives in template.ts as a tree of elements with flexbox styles, no React. It always uses the dark theme, like the logo’s neon version. The title’s font size shrinks with its length, so a one-word page still reads well as a thumbnail; the title is cut at three lines and the description at two, with “…”.
Satori draws, sharp compresses
Satori turns that tree into an SVG, with the text as paths, and sharp turns it into a PNG:
async function draw(tree: unknown): Promise<Buffer> {
// Satori's types expect a React element; the tree has the same shape.
const svg = await satori(tree as Parameters<typeof satori>[0], { ...OG_SIZE, fonts })
return sharp(Buffer.from(svg)).png(PNG_OPTIONS).toBuffer()
}
For the PNG I considered resvg, but it would have been one more native dependency, and sharp was already installed because Astro uses it. On top of that, sharp saves the PNG with a palette, which makes it less than half the size: each image weighs about 55 KB. There’s no dithering (dither: 0), because on the background’s violet glow it drew a visible noisy patch.
The versions follow the 14-day rule (DEC-002): Satori 0.63.2 and sharp 0.35.5, the same one Astro already brought in, now as a direct dependency.
The fonts: static instances, not variable ones
This was the surprise. The site uses the variable fonts of Unbounded and Instrument Sans, and titles use weight 600. But Satori draws a variable font only at its default weight, 400: with the fonts I already had installed, everything would have come out at 400.
The fix was to use the static instances of the same fonts, from @fontsource: Unbounded at 600 and Instrument Sans at 400 and 600, in Latin and Latin Extended, so accents, ñ, opening marks like ¿ and ¡, and quotes all fit. They’re read from node_modules, with no network. The site keeps the variable fonts; only the build uses the static ones.
A cache so nothing is drawn twice
Drawing every image took about 2.7 s the first time, with 52 images when I measured it. That’s not much, but it’s paid on every build and almost always for nothing: most pages didn’t change.
So each image is saved in node_modules/.astro/og-images/, under a name that’s the hash of everything that shapes it: the element tree (text, design and domain) and whatever changes the pixels without changing the tree.
const salt = JSON.stringify({
fonts: FONT_FILES.map((f, i) => [f.file, fonts[i]?.data.byteLength]),
satori: satoriVersion,
sharp: sharp.versions,
png: PNG_OPTIONS,
})
If I change a title, only that image is drawn again; if I update Satori, all of them are. With a warm cache, the step takes 0.1 s. Images nobody has used in 30 days are deleted on their own, and if I ever want to start from scratch, I just delete the folder.
What the tests check
A broken image in a link preview isn’t seen by whoever built the site: it’s seen by whoever gets the link. That’s why there are two safety nets.
The first is scripts/check-links.mjs, which already checked the build’s internal links and now also checks the URLs in og:url, og:image and twitter:image. Along the way, the 404 got fixed: it wrote og:url as /404/, an address that doesn’t exist. Now it doesn’t write it, just as it has no canonical link.
The second is e2e/og-images.e2e.ts. For every public page in the build, it checks that the page points to its own image, on the same domain as the page, and that the file exists and is a 1200 × 630 PNG:
const file = join(DIST, image.pathname)
expect(existsSync(file), `${image.pathname} is in the build`).toBe(true)
expect(pngSize(file)).toEqual(OG_SIZE)
Then it builds the card with the same function the build used and requires it to show the page’s title and description, in its language. On posts, it also requires the reading time to be the one shown under the title, and BlogPosting to use the post’s own image. And the other way around: every image in /og/ must belong to a page, so none are left orphaned.
Check it yourself
- The decision, the versions and what I ruled out:
docs/DECISIONES.md(DEC-029, in Spanish). - The integration, the drawing and the cache:
integration.tsandrender.ts. - The design and its texts:
template.tsandmessages.ts. - How to change the design (in Spanish):
docs/CONTENIDO.md, section «Imágenes para compartir». - The test:
e2e/og-images.e2e.ts.
And the quickest way to see it: share any page of this site in a chat, this post included, and look at its card. The full picture of how the site is built is in How it’s built, and the other half of the <head> tags, in the strict CSP.