// Transmission

Per-post OG images with Satori on Cloudflare Pages

Wiring Satori and resvg into Astro's static build so every post gets its own 1200×630 PNG. No Workers, no external service, no runtime surprises.

Date
Read
4 min read
  • #astro
  • #satori
  • #cloudflare
  • #og

Every post on this site now ships with its own Open Graph image. The title, description, date, and tags get baked into a 1200×630 PNG at build time, written to /og/<slug>.png, and referenced from the post’s <meta property="og:image">. No runtime, no third-party service, no Worker. Two npm packages and a static endpoint.

Here’s how it’s wired.

The shape of the problem

Astro’s static build is the easiest surface to target. I don’t need a serverless function. I need a build-time hook that can read the blog collection and emit one PNG per post. Astro already has this: any file under src/pages/ that exports getStaticPaths becomes a set of static routes, and a route ending in .png.ts can return a Response whose body is a Uint8Array.

So the plan is one file:

src/pages/og/[slug].png.ts
export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) =>
    import.meta.env.PROD ? !data.draft : true,
  );
  return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}

export const GET: APIRoute = async ({ props }) => {
  const png = await generateOgImage({ /* ... */ });
  return new Response(new Uint8Array(png), {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=31536000, immutable',
    },
  });
};

The draft filter matches the post page itself, so drafts don’t get an OG image in production. Same rule that hides them from the index.

Satori + resvg

Satori takes a React-ish element tree and a set of font buffers, and returns SVG. Resvg turns that SVG into a PNG. Both are pure Node, which matters: Cloudflare Pages’ build step runs on Linux Node, and @resvg/resvg-js ships prebuilt native binaries for that target.

src/lib/og.tsx
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';

export async function generateOgImage(input: OgInput): Promise<Uint8Array> {
  const fonts = await loadFonts();
  const svg = await satori(<OgTemplate {...input} />, {
    width: 1200,
    height: 630,
    fonts,
  });
  return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } })
    .render()
    .asPng();
}

The template itself is plain flex layout. Satori’s subset of CSS is small but honest: no color-mix(), no CSS variables, no grid. Every element needs display: flex or it silently misrenders. I inlined the theme’s amber and background hex values, since Satori can’t resolve the site’s CSS variables.

One thing to watch for in dev: @resvg/resvg-js ships a .node native binary, and Vite tries to pre-bundle it into browser code. The fix is two lines of Astro config to mark it SSR-external and skip dep optimization. Took me a good 5 minutes of staring at an esbuild loader error before I figured that out.

Fonts are the whole trick

Satori doesn’t bundle fonts. It won’t read the system’s Orbitron even if one exists. You have to hand it Buffer (or ArrayBuffer) data for every font family and weight you reference.

The easiest reliable source is the Fontsource npm packages. They ship per-weight, per-subset .woff files (which Satori accepts directly) and resolve cleanly through createRequire:

ts
const require = createRequire(import.meta.url);
const [orbitron900, orbitron700, mono400] = await Promise.all([
  readFile(require.resolve('@fontsource/orbitron/files/orbitron-latin-900-normal.woff')),
  readFile(require.resolve('@fontsource/orbitron/files/orbitron-latin-700-normal.woff')),
  readFile(require.resolve('@fontsource/geist-mono/files/geist-mono-latin-400-normal.woff')),
]);

That fonts array is cached at module scope so the 20-odd posts don’t each re-read the same three files from disk.

The design

The card mirrors the static /og.png I already had: amber corner brackets, a grid of faint horizontal lines, an LL monogram chip, and a title block in Orbitron. The per-post template adds the date, a row of tag chips, and a description paragraph under the title. Font sizes drop a step when the title is longer than 48 characters, so the text stops running off the right frame.

tsx
<div style={{
  fontFamily: 'Orbitron',
  fontWeight: 900,
  fontSize: safeTitle.length > 48 ? 60 : 76,
  lineHeight: 1.05,
  letterSpacing: 2,
  textTransform: 'uppercase',
  textWrap: 'balance',
}}>
  {safeTitle}
</div>

textWrap: 'balance' is the one piece of modern CSS Satori does understand, and it’s the single biggest readability win for titles that wrap to two or three lines.

Wiring it into the post page

The Astro post layout takes an ogImage prop that falls through to the Open Graph meta tags in Base.astro. One line does it:

astro
<Base
  title={post.data.title}
  description={post.data.description}
  ogType="article"
  ogImage={`/og/${post.id}.png`}
>

The Base layout already normalizes that path to an absolute URL using Astro.site, so the final <meta property="og:image"> points at https://leandrolugaresi.com.br/og/<slug>.png, which is what Twitter, LinkedIn, and iMessage want.

Why build-time and not runtime

Cloudflare Pages has Workers. I could have put this behind a Function and generated the PNG on request. I went with the build step for a few reasons.

OG images don’t change between deploys. Every cache lookup returns the same bytes until I edit the post. Generating once and serving from Cloudflare’s static asset CDN is cheaper and faster than any Worker path I could design.

The content is already known at build time. The blog collection is loaded, the title and tags are typed. Running Satori in a Worker would mean re-reading the MDX and re-loading fonts on every cold start.

And debugging is easier. If a title breaks the layout, bun run build drops the broken PNG into dist/og/ and I can open it locally before pushing anything.

Cost

Build time went from ~2.3s to ~2.9s with one post generating an OG image. Each additional post adds maybe 50ms of Satori + resvg work on a warm process. That’s a budget I’m comfortable spending.


If you’re looking at this post’s OG card right now, that’s the output. Same pipeline, rendered at build time, shipped as a static asset. Full source is two files: src/lib/og.tsx and src/pages/og/[slug].png.ts.

ShareTwitterLinkedIn
LL
Operator

Leandro Lugaresi

Backend & systems engineer. Writing about Go, data pipelines, and the Cloudflare playground.

GitHub