Dynamic OG images in Astro, without next/og
· Rizkyy. · 6 min read
On this page
Every post on this site shared the same social image. Paste a link into Slack and you get the site portrait, the same picture whether the post is about payments infrastructure or a small object library. The preview told you a link existed. It told you nothing about what was behind it.
So: a card per post, with the title and date on it.
The spec assumed a framework I don't use
I had a layout spec written already, a design called "Gutter". Exact geometry: 1200×630, an 80px margin, a vertical hairline at x=287 splitting a metadata gutter from the title column, a meta rule at y=505, the brand lockup below it.
The spec was written for Next.js App Router and next/og. This site is Astro. Its own first line says what to do about that: keep every number, swap the renderer.
That turns out to be the honest description of next/og. It is a wrapper around satori, which turns a React-ish element tree into SVG, plus a rasteriser. Take the wrapper off and you get the same two steps, explicitly:
satori(tree, { width, height, fonts }) -> SVG
new Resvg(svg).render().asPng() -> PNGBoth run in Node. Neither needs Next.
No JSX, on purpose
Satori's documented input is JSX. Adding JSX to an Astro project that ships no client framework means a compiler config, a jsxImportSource, and a dependency, all so one build-time module can write four divs.
Satori also accepts a plain object tree, JSX is only sugar for exactly this shape:
type Node = {
type: string;
props: Record<string, unknown> & { children?: Node | Node[] | string };
};
const box = (
style: Record<string, unknown>,
children?: Node | Node[] | string,
): Node => ({ type: "div", props: { style, children } });One helper, and the layout reads as data:
box({
position: "absolute",
left: 287,
top: 72,
width: 1,
height: 494,
backgroundColor: RULE,
});That is the vertical hairline, at the coordinates the spec gives. No build step was added to draw it.
The route is the interesting decision
The obvious shape is /og?title=..., and the spec includes it. It also warns against it, and the warning is the right one: a query-string renderer will draw anything anyone passes it, on your domain, cached for a year. Someone posts a link preview showing text you never wrote, served from your own URL. Nothing in the request is a lie, you really did render it.
The fix is to stop taking input. In Astro, the card is a prerendered endpoint that reads titles from the CMS at build time:
export const prerender = true;
export const getStaticPaths: GetStaticPaths = async () => {
const cards = await getAllPostCards();
return cards.map((card) => ({
params: { slug: card.slug },
props: {
title: card.title,
date: formatPostDate(card.publishedAt),
readingTime: formatReadingTime(
card.content ? readingMinutes(card.content) : 1,
),
},
}));
};Every published post gets dist/og/<slug>.png at build. No function runs in production, nothing accepts a parameter, and the cards are static files on the CDN. The post pages here are already prerendered, so a new post needs a build regardless, the cards cost nothing extra in freshness.
Fonts are where the sharp edges are
Three things, each of which fails quietly rather than loudly.
Satori cannot read woff2. This site self-hosts Iosevka as woff2, which is the right format for a browser and useless here. Cards need static TTF or OTF.
Only registered weights render. A weight you reference but never load falls back silently, and every measurement in a calibrated layout stops holding. I hit this exactly once: the wordmark was set at 400 while the card registered 500 and 600. It rendered, visibly heavier than intended, with no error anywhere.
The fonts do not belong in public/. Astro copies that directory to the CDN, which would have shipped 884kB of TTF to every visitor for files only the build ever opens. They live in src/assets/fonts/ and are read with process.cwd(), which is reliable precisely because these modules only ever run during the build.
For the typeface itself I used Inter Tight rather than the Inter the spec calibrated against, because it is what the site's own headings use, and being narrower, titles clear the meta rule with more room, not less. Deviating toward the tighter face is safe. It would not have been the other way around.
Four lines, actually
Long titles were the real layout risk: run past four lines and the title collides with the meta rule at y=505. The spec's answer is a step function on character count, then a hard truncate at 150 characters:
function titleSize(title: string) {
if (title.length <= 48) return 62;
if (title.length <= 80) return 58;
if (title.length <= 120) return 48;
return 42;
}Character count is a proxy for rendered width, not the real thing. A 176-character test title came back as five lines, the fifth holding two words and an ellipsis.
The spec calls satori's line-clamp support version-dependent and declines to rely on it. Worth two minutes to check rather than inherit: on satori 0.29, lineClamp: 4 with display: "block" works, and breaks with a proper ellipsis on the fourth line. So the clamp does the real work now, and the character truncate stays as a backstop rather than as the mechanism.
Version-dependent means test it, not avoid it.
A type error worth reading
The route wouldn't compile:
Argument of type 'Buffer<ArrayBufferLike>' is not assignable to parameter of type 'BodyInit'resvg returns a Node Buffer. Under @types/node 26, Buffer is generic over its backing store, and ArrayBufferLike includes SharedArrayBuffer, which BodyInit will not take. The error is pedantic and also correct. One copy settles it:
// resvg hands back a Node Buffer, whose ArrayBufferLike backing store is not a
// valid `BodyInit`. Copy into a plain Uint8Array so the route can return it.
return new Uint8Array(png);40kB, at build time, once per post. Cheap enough not to think about.
Reading time, and a bug it exposed
With a card layout comes empty space, and the bottom right had some. Reading time went there uppercase, muted, same treatment as the date, so the two sit on opposite corners of the composition.
Counting words is easy to get wrong in one specific way: leave code fences in and a short post with one long listing claims eleven minutes. Nobody reads a listing at prose speed. Strip fences, inline code, and link syntax first, then divide by 200:
export function readingMinutes(markdown: string): number {
const prose = markdown
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]*`/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[#>*_~-]/g, " ");
const words = prose.split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.ceil(words / READING_SPEED));
}Putting the same figure in the post's meta line, next to the view count, is where a much older bug surfaced. The counter is hidden until its number arrives:
<span
data-views="{post.slug}"
hidden
class="inline-flex items-center gap-x-1.5"
></span>The eye showed up anyway, from first paint, sitting next to nothing.
The markup was right. Tailwind's preflight hides [hidden], but at specificity (0,1,0), the same as the inline-flex utility on that span. Equal specificity, so order decides, and utilities come after base. display: inline-flex won, and the attribute did nothing at all.
[hidden] {
display: none !important;
}I had read that markup several times looking for the fault. It was never in the markup. Two rules of equal weight, and the loser was the one that carried the meaning.
What it costs
Two dependencies, three TTF files outside the published output, 230 lines across a renderer and a route. Roughly two seconds a card at build. No runtime, no function, no endpoint that will render a stranger's text under my domain.
The spec that started it was written for a framework I don't use. Almost all of it survived anyway, every coordinate, the sizing function, the truncate, the warning about the open endpoint. What changed was the renderer and the route shape. Geometry travels better than framework APIs do.