Tutorial
Auto-Generate Open Graph Images in Astro with Satori (2026)
When someone shares your post on X, Slack, or Discord, the OG image is the first impression. A generic site-wide banner works, but a card that shows the actual post title gets measurably more clicks — and once you have more than a handful of posts, hand-making images in Figma stops being fun.
This site generates one automatically for every post. Here’s the full setup, straight from this repo’s source: Satori turns a JSX-shaped object into SVG, resvg rasterizes it to PNG, and an Astro static endpoint runs both at build time. The output is plain PNG files in dist/ — no serverless function, no runtime cost, works on any static host (including Cloudflare Workers static assets, where this site runs).
Here’s the exact card this post generates for itself:

Why Satori and not a headless browser?
The classic approach is screenshotting an HTML template with Puppeteer. It works, but it drags a full Chromium download into your build and adds seconds per image. Satori is a ~1 MB pure-JS layout engine: it supports a useful subset of CSS (flexbox, borders, gradients, text clamping) and renders in tens of milliseconds per card.
There’s also astro-og-canvas if you want a preset look with less control. We’re doing it directly because a theme maker’s OG card should match the site’s typography — and because the direct version is barely more code.
1. Install
pnpm add satori @resvg/resvg-js
2. Fonts — the part that will actually bite you
Satori requires embedded font data for every glyph it draws, and it has two hard limitations:
- No
woff2. Onlyttf,otf, andwoff. - No variable fonts. You need static single-weight files.
If you use Fontsource like we do, check what’s actually in the package. @fontsource/instrument-serif ships both woff and woff2 — great. @fontsource-variable/* packages ship variable woff2 only — useless for Satori. Copy the static woff files somewhere stable so your build doesn’t depend on node_modules internals:
mkdir -p src/assets/og
cp node_modules/@fontsource/instrument-serif/files/instrument-serif-latin-400-normal.woff src/assets/og/
cp node_modules/@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff src/assets/og/
3. The endpoint
Create src/pages/og/[slug].png.ts. In Astro, a .png.ts file is a static endpoint: getStaticPaths enumerates every post, and the GET handler returns PNG bytes that Astro writes to dist/og/<slug>.png during astro build.
Satori accepts React elements, but you don’t need React (or a .tsx file) — it just reads { type, props: { style, children } } objects, so a tiny factory function is enough:
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
const SITE = 'p4ni';
const TAGLINE = 'Astro themes & tutorials';
const DOMAIN = 'astro.p4ni.com';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const fontDir = path.join(process.cwd(), 'src/assets/og');
const serifNormal = await readFile(path.join(fontDir, 'instrument-serif-latin-400-normal.woff'));
const serifItalic = await readFile(path.join(fontDir, 'instrument-serif-latin-400-italic.woff'));
// Satori consumes React-shaped objects; no JSX needed.
function el(
type: string,
style: Record<string, unknown>,
children?: unknown
): Record<string, unknown> {
return { type, props: { style, children } };
}
export const GET: APIRoute = async ({ props }) => {
const { post } = props;
const title: string = post.data.title;
// Scale the type down as titles get longer.
const titleSize = title.length > 70 ? 56 : title.length > 45 ? 64 : 76;
const card = el(
'div',
{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: '#faf6ef',
padding: 40,
fontFamily: 'Instrument Serif',
},
[
el(
'div',
{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
border: '2px solid #c9bda6',
padding: '48px 56px',
},
[
el('div', { fontSize: 44, color: '#211d18' }, SITE),
el(
'div',
{
display: 'flex',
fontSize: titleSize,
lineHeight: 1.08,
color: '#211d18',
lineClamp: 4,
},
title
),
el(
'div',
{
display: 'flex',
justifyContent: 'space-between',
width: '100%',
borderTop: '1px solid #e2d9c8',
paddingTop: 28,
},
[
el('div', { fontSize: 28, color: '#857c6f' }, DOMAIN),
el('div', { fontSize: 28, color: '#575046', fontStyle: 'italic' }, TAGLINE),
]
),
]
),
]
);
const svg = await satori(card as never, {
width: 1200,
height: 630,
fonts: [
{ name: 'Instrument Serif', data: serifNormal, weight: 400, style: 'normal' },
{ name: 'Instrument Serif', data: serifItalic, weight: 400, style: 'italic' },
],
});
const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
return new Response(new Uint8Array(png), {
headers: { 'Content-Type': 'image/png' },
});
};
A few Satori-specific details in there:
- Every multi-child container needs
display: 'flex'. Satori only implements flexbox layout; it throws if an element has several children and no explicit display value. lineClamp: 4truncates long titles with an ellipsis instead of overflowing the card.- The font-size ladder (
76 → 64 → 56) keeps short titles punchy and long ones on the card. Crude, but it beats measuring text. - Fonts are read once at module scope, not per request — this file runs in Node during the build, so
node:fsis fine.
4. Wire it into your layout
Point each post’s og:image at the generated file. In your post page:
---
const ogImage = post.data.ogImage ?? `/og/${post.id}.png`;
---
<BaseLayout ogImage={ogImage} ogImageAlt={post.data.title} ogType="article" />
Keeping an ogImage frontmatter field as an override is worth the two lines: some posts deserve a hand-made image, and the fallback chain (frontmatter → generated → site default) costs nothing.
In the layout head, emit the absolute URL plus dimensions — some scrapers render the card faster when width and height are declared:
<meta property="og:image" content={new URL(ogImage, Astro.site).href} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={ogImageAlt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={new URL(ogImage, Astro.site).href} />
5. Verify
Run pnpm build and check the log — each image shows up as its own route:
├─ /og/astro-og-images-satori.png (+1.71s)
Open the PNG from dist/og/ directly, then paste a deployed post URL into opengraph.xyz to see the real card. If you’re iterating on the design, pnpm dev serves the endpoint live at /og/<slug>.png — refresh the browser tab after each tweak instead of rebuilding.
The ~1.7s in that log line is mostly Satori’s one-time initialization; subsequent images in the same build render in a fraction of that. Even at fifty posts this stays a rounding error in total build time.
Gotchas recap
woff2and variable fonts silently don’t exist as far as Satori is concerned — convert or use staticwoff/ttf.- Multiple children ⇒ explicit
display: 'flex', every time. - Only a CSS subset is supported: no
grid, noposition: sticky, no pseudo-elements. Check the Satori README before designing something elaborate. - Emoji and CJK text need extra font data — every glyph must exist in a font you pass in.
- Draft posts: filter them in
getStaticPaths(as above) or you’ll generate images for posts that don’t exist publicly.
That’s the whole system: one endpoint file, two dependencies, two font files. Every future post on this site gets a branded card for free — the kind of small automation that compounds when you’re shipping a lot of Astro projects.