Until recently, every page on this site shared the same Open Graph image. It worked, but sharing a post titled Hello World with a generic site logo was not especially exciting.
I wanted each post to have its own social card without opening an image editor every time I published something. I asked Codex how Cassidy Williams generates Open Graph images for her Astro blog and whether we could build something similar here. Her version uses Puppeteer to screenshot an Astro page; ours takes a slightly different route, using Satori and Sharp to render the card without launching a browser.
The result now runs as part of every Astro build. A post title goes in, and a 1200 × 630 PNG comes out with the same colors and typography as the rest of the site.
Generating PNG files with Astro
Astro endpoints can return files instead of HTML. A route named src/pages/og/[...slug].png.ts becomes a collection of real .png files during a static build.
The route gets the blog posts, skips posts with a custom ogImage, and returns one path per title:
export const getStaticPaths = async () =>
getOgImagePosts(await getBlogPosts())
.filter((post) => !normalizeOgImageOverride(post.data.ogImage))
.map((post) => ({
params: { slug: getOgImageSlug(post.id, post.data) },
props: { title: post.data.title },
}));
export const GET = async ({ props }) => {
const image = await renderOgImage(props);
return new Response(new Uint8Array(image), {
headers: { 'Content-Type': 'image/png' },
});
};
The current visibility rules also keep private posts out of the public build. Public and unlisted posts can have social cards because they can be shared; private posts stay in the private site.
From JSX to pixels
Satori turns a limited subset of HTML and CSS into SVG. The template is JSX with inline styles, so it feels like writing a small web page, as long as that page is made almost entirely of Flexbox.
Sharp handles the last step. With the local font-loading setup omitted, the core conversion looks like this:
export async function renderOgImage({ title }: { title: string }) {
const svg = await satori(
<div style={{ backgroundColor: '#04068f' }}>
<div>{title}</div>
</div>,
{ width: 1200, height: 630, fonts }
);
return sharp(Buffer.from(svg)).png().toBuffer();
}
The full template adds the site name, the LL mark, a purple stripe, and two rotated squares. The title size changes at a few length thresholds, then lineClamp stops exceptionally long titles at five lines.

Fonts and emoji are not free
Satori does not quietly borrow the system fonts installed on the build machine. We bundle Source Sans 3 and Source Serif 4 so the output is deterministic, plus Noto Serif JP for Japanese text.
Emoji needed another path. The first attempt used an emoji font, but it did not reliably handle compound emoji such as 👋🏽. The renderer now uses Satori’s loadAdditionalAsset hook to load matching local SVG files from Twemoji. That keeps the build offline and lets a title contain Japanese text and compound emoji without turning parts of it into empty boxes.
Giving social crawlers a new URL
Social platforms cache images aggressively. Rebuilding /og/post.png does not guarantee that anyone will fetch the new version.
Each filename therefore includes a short hash of the title and a template version:
hash(
JSON.stringify({
template: TEMPLATE_VERSION,
title,
})
);
Changing a title creates a new URL automatically. For a visual-only template change, I bump TEMPLATE_VERSION. The old cached image can remain cached forever; the page points to a different filename.
The blog layout adds that absolute URL to og:image and twitter:image, along with the dimensions, alt text, publication time, and tags. A post can still provide an ogImage in frontmatter when it needs a completely custom card.
The parts that broke
The first working renderer failed inside Astro’s Vite build because Vite rewrote import.meta.resolve() to an SSR shim that did not implement it. Switching package asset lookup to createRequire(import.meta.url).resolve() fixed both font and Twemoji loading.
Sharp also existed elsewhere in Astro’s dependency tree. Adding a direct dependency was not enough: the build initially contained two Sharp and libvips versions, including an older vulnerable one. Pinning Sharp in pnpm-workspace.yaml made the whole workspace use one version.
The tests now render an actual PNG and inspect its dimensions. They also cover content-addressed URLs, blank custom-image overrides, Japanese text, compound emoji, clipped descenders, and the five-line title limit. That is more confidence than I expected to need for what started as “put the title on a blue rectangle.”
What came from where
Astro provides the static endpoint. Satori provides the layout engine. Sharp provides PNG conversion. Fontsource packages the fonts, and Twemoji provides the emoji artwork. We used documented APIs from each of them.
The site-specific work is the template, title-sizing rules, content-addressed filenames, font and emoji integration, metadata, frontmatter override, visibility rules, and tests. Codex assembled those pieces for this repository after researching Cassidy’s approach; it did not paste her Puppeteer script or copy her visual design.
Now publishing a post also publishes its social card. I still have plenty of opportunities to overthink the title, but at least I no longer need to manually typeset it twice.
