Back to Lab
RAXXO Studios 9 min read No time? Make it a 1 min read

Generate Open Graph Images On the Fly With Satori and Resvg

Node
9 min read
TLDR
×
  • Satori turns JSX into SVG with zero browser, runs in 40ms
  • Resvg renders SVG to PNG in pure Rust, no Puppeteer
  • Full edge function code that caches per-page cards
  • Each blog post gets a unique share image automatically

Every link I shared on social used to show the same generic banner. Now every page on raxxo.shop generates its own Open Graph card with the title, the author, and a clean layout, all rendered on the fly. No headless browser, no screenshot service, no build step. Here is the entire pipeline.

Why Skip the Headless Browser

The standard way to make share cards is Puppeteer. You spin up a headless Chromium, load an HTML page, screenshot it, save the PNG. It works. It is also slow, heavy, and a pain to run on an edge function.

Chromium is roughly 280MB unpacked. Most edge runtimes cap your bundle at 50MB or refuse to launch a browser at all. Cold starts run 800ms to 2 seconds because the browser has to boot before it draws a single pixel. I ran a Puppeteer card service for a while and the p95 latency sat around 1.4 seconds. For an image that loads in the background of a social preview, that is fine until you have 60 posts and a crawler hits them all at once.

The alternative is Satori plus Resvg. Satori is a library that takes JSX (or a plain object tree) and produces an SVG string. It does the layout in JavaScript using a flexbox engine, so no browser, no DOM. Resvg then takes that SVG and rasterizes it to PNG using Rust compiled to WebAssembly. Both run inside a normal serverless or edge function.

The numbers that sold me: Satori renders a typical 1200x630 card in about 40ms. Resvg converts the SVG to PNG in another 30 to 60ms depending on font count. Total cold path under 200ms, warm path closer to 90ms. The whole bundle is under 8MB instead of 280MB.

There is a tradeoff. Satori only supports a subset of CSS. Flexbox works. Grid does not. No CSS animations, no filters beyond a few, no external stylesheets. You build cards with flex containers, fixed positions, colors, and text. For a share card that is exactly the vocabulary you need anyway, so the limit rarely bites.

If you want the broader pattern of doing real work at the edge instead of on a fat server, the same thinking shows up in how I structure the rest of the stack. Background: Claude Blueprint goes through how I keep services small and composable.

The Minimal Satori Setup

Install two packages: `satori` and `@resvg/resvg-js`. You also need at least one font as a buffer, because Satori cannot guess a typeface the way a browser falls back to system fonts.

Here is the core function. I am using the object form instead of real JSX so it runs without a JSX transform.


import satori from 'satori'
import { Resvg } from '@resvg/resvg-js'
import fs from 'node:fs/promises'

const fontData = await fs.readFile('./fonts/Inter-Bold.ttf')

export async function renderCard({ title, tag }) {
  const svg = await satori(
    {
      type: 'div',
      props: {
        style: {
          width: '1200px',
          height: '630px',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          padding: '64px',
          background: '#0b0b0f',
          color: '#ffffff',
        },
        children: [
          {
            type: 'div',
            props: {
              style: { fontSize: '28px', color: '#9aa0ff' },
              children: tag.toUpperCase(),
            },
          },
          {
            type: 'div',
            props: {
              style: { fontSize: '72px', lineHeight: 1.1 },
              children: title,
            },
          },
          {
            type: 'div',
            props: {
              style: { fontSize: '32px', color: '#8a8a93' },
              children: 'raxxo.shop',
            },
          },
        ],
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [{ name: 'Inter', data: fontData, weight: 700, style: 'normal' }],
    }
  )

  const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } })
  return png.render().asPng()
}

That is the whole thing. Satori returns an SVG string. Resvg takes it, renders, and `asPng()` hands back a buffer of bytes you can return as `image/png`.

A few details that cost me time. Every `div` with more than one child needs `display: flex` set explicitly, or Satori throws. It does not assume block layout. Fonts must be `.ttf` or `.otf`, not `.woff2`, because the SVG renderer cannot decompress woff2. If your title overflows, set a `maxWidth` and Satori wraps it for you, but only if `display: flex` and `flexWrap` or a fixed width is present.

For 1200x630, the magic numbers are 64px padding and a 72px headline. That sizing reads cleanly when a platform shrinks the preview to a thumbnail. Smaller text turns to mush at thumbnail scale.

Serving It From an Edge Function

The render function is half the job. The other half is wiring it to a URL so `` can point at it.

I expose a route like `/og?title=...&tag=...`. The function reads the query params, calls `renderCard`, and returns the PNG with cache headers. The cache headers matter more than the render speed, because once a card is generated the crawler should never trigger a fresh render again.


export default async function handler(req) {
  const url = new URL(req.url)
  const title = url.searchParams.get('title') ?? 'Untitled'
  const tag = url.searchParams.get('tag') ?? 'Lab'

  const png = await renderCard({ title, tag })

  return new Response(png, {
    headers: {
      'content-type': 'image/png',
      'cache-control': 'public, immutable, max-age=31536000',
    },
  })
}

The `immutable, max-age=31536000` line tells the CDN to hold the result for a year. Because the title and tag are in the URL, any change to a post title produces a new URL and a new cache key automatically. No manual invalidation.

One trap: passing raw user text in a query string breaks on special characters. I `encodeURIComponent` the title when I build the meta tag, and the function decodes it through `URLSearchParams` for free. Long titles also need a length cap, around 90 characters, or the layout breaks. I truncate with an ellipsis before passing it in.

I load the font once at module scope, outside the handler, so it stays warm between invocations on the same instance. Reading a 350KB font file on every request added 15ms I did not need. Hoisting it out cut warm latency by that amount across thousands of calls.

For social scheduling I push the generated card URLs straight into Buffer so each scheduled post pulls its own preview. If you generate the images first and let the platform crawl them once, the queue stays fast and nothing renders twice. The whole point is that the heavy work happens at the edge near the user, not on a single origin server that becomes a queue when a crawler fans out across your sitemap.

Templates, Fonts, and the Edge Cases

A single layout gets boring after a dozen posts. I keep three template functions: one for tutorials, one for case studies, one default. The route picks one based on a `template` param. Each is just a different object tree, maybe a different accent color and a small icon.

Emoji is the first thing that breaks. Satori does not ship an emoji font, so a 🚀 renders as a blank box. The fix is to pass a `loadAdditionalAsset` callback that fetches the right glyph from an emoji CDN on demand. I cache those fetches in a Map keyed by code point, so the same emoji never downloads twice within a warm instance. With 12 common emoji preloaded, the extra latency dropped to near zero.

Multiple font weights matter more than I expected. A card with one weight looks flat. I load Inter at 400, 600, and 700 and assign weights per element. The bundle grows by about 700KB total, which is nothing next to a browser. Mixing a regular tag label against a bold headline gives the card a real visual hierarchy that reads at thumbnail size.

Images inside cards are the trickiest part. Satori accepts an `` with a `src`, but the src must be a base64 data URI or a fully resolvable URL, and Resvg has to fetch and decode it during rasterization. A remote PNG logo adds 40 to 120ms. I inline my logo as a base64 string baked into the template, so there is no network hop at render time.

Color and contrast deserve a test pass. I render every template once and check it on a phone-sized preview, because what looks crisp at 1200px wide can vanish at 300px. Light gray subtitles below `#888` disappear against dark backgrounds when compressed by the platform.

For the deeper version of how I keep these template files small and reusable, I covered the same modular approach in Claude Blueprint. The principle carries: small typed functions, one job each, no clever inheritance. A card template is 30 lines. When I want a fourth style I copy one, change the color tokens, and ship it the same day.

Bottom Line

Satori plus Resvg replaced a 280MB browser with an 8MB pipeline that renders a share card in under 200ms cold and 90ms warm. JSX goes in, SVG comes out, PNG bytes come back, and a one-year cache header means each unique title renders exactly once before the CDN takes over. No screenshot service, no Chromium, no build step.

The setup that took me the longest was not the render code. It was the font handling, the emoji callback, and the cache headers, which is where the real latency lives. Get those three right and the rest is just designing object trees that happen to look like flexbox.

If you run a content site or a store with lots of pages, this is the highest-use afternoon project I know of. Every link you share starts looking deliberate instead of generic. Start with one template, wire the route, point your meta tag at it, and add styles as you go. The code above is the whole skeleton. Copy it, swap the font, and you have unique cards by tonight.

This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)

Stay in the loop
New tools, drops, and AI experiments. No spam. Unsubscribe anytime.
Back to all articles