p4ni.

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:

Auto-generated Open Graph image for this post — warm paper background with the post title set in a serif face, rendered by Satori at build time.

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. Only ttf, otf, and woff.
  • 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: 4 truncates 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:fs is 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

  1. woff2 and variable fonts silently don’t exist as far as Satori is concerned — convert or use static woff/ttf.
  2. Multiple children ⇒ explicit display: 'flex', every time.
  3. Only a CSS subset is supported: no grid, no position: sticky, no pseudo-elements. Check the Satori README before designing something elaborate.
  4. Emoji and CJK text need extra font data — every glyph must exist in a font you pass in.
  5. 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.