Skip to main content
SEO

Automated OG image generation: dynamic social cards, with or without a screenshot API

Three ways to generate a per-page og:image — Satori/next-og JSX rendering, a headless browser screenshot, or a hosted API — with the real CSS limits, cold-start costs, and the caching header Next.js gets wrong.

Thien Nguyen
By Thien Nguyen
Updated August 2, 2026 · 8 min read

Nothing here is broken:

$ curl -s https://example.com/tool/uuid-generator/  | grep -o 'og:image[^>]*'
og:image" content="https://example.com/og-default.png
$ curl -s https://example.com/tool/json-formatter/  | grep -o 'og:image[^>]*'
og:image" content="https://example.com/og-default.png
$ curl -s https://example.com/journal/cors-explained/ | grep -o 'og:image[^>]*'
og:image" content="https://example.com/og-default.png

The tag is present, server-rendered, absolute, and 1200×630. Every scraper is happy. And 800 pages share one card, so a link to your JSON formatter and a link to your CORS deep-dive unfurl in Slack as the identical logo tile. That's not a bug you can debug — it's a build step you never wrote. (If your card is genuinely broken rather than merely generic, that's a different problem, and it lives in Open Graph tags: stop your links looking terrible on Slack.)

There are three honest ways out, and the right one depends almost entirely on how many pages you have and whether your card needs a real browser to draw it. If the answer is "a dozen pages, hand-tuned," stop reading and go use the Open Graph image generator — it composes and downloads a PNG in the browser and you commit the files. Automation only pays off past the point where hand-making them stops scaling.

Option 1: render JSX to PNG with Satori

Satori takes a JSX tree, lays it out with Yoga, and emits SVG; resvg rasterizes that to PNG. No browser, no Chromium binary. In Next.js it arrives pre-bundled as ImageResponse — imported from next/og since v14.0.0 (it moved out of next/server, and the standalone @vercel/og package is now only for the Pages Router and non-Next projects).

A complete, runnable route that takes the title from the query string:

// app/api/og/route.tsx
import { ImageResponse } from "next/og";

export const runtime = "nodejs";

export async function GET(req: Request) {
  const title =
    new URL(req.url).searchParams.get("title")?.slice(0, 100) ?? "Untitled";

  return new ImageResponse(
    (
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          width: "100%",
          height: "100%",
          padding: 64,
          background: "linear-gradient(135deg, #0f172a, #1e3a8a)",
          color: "white",
          fontSize: 64,
          fontFamily: "sans-serif",
        }}
      >
        <div style={{ display: "flex", fontSize: 28, opacity: 0.7 }}>
          example.com
        </div>
        <div style={{ display: "flex", lineHeight: 1.15 }}>{title}</div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      headers: {
        "cache-control": "public, immutable, no-transform, max-age=31536000",
      },
    },
  );
}

Two things in there are not decoration. Every div carries an explicit display: "flex" (see below), and the headers override exists because the default is wrong (see further below).

The length cap on title earns its place as well. An image endpoint that renders arbitrary text from an unauthenticated query string is a free render farm for anyone who finds it — cap the length, and if the endpoint is expensive, sign the params with an HMAC or derive them server-side from the slug instead of trusting the URL.

What Satori will refuse to render

This is where the approach either fits your design or doesn't, and the failure everyone hits first has a genuinely misleading error message:

Expected <div> to have explicit "display: flex", "display: contents",
or "display: none" if it has more than one child node.

"More than one child node" is not the condition. The guard in src/layout.ts fires when the child is anything other than a plain string — so a div wrapping a single element throws too, and giving the child position: absolute doesn't exempt it. The CSS validator also accepts block and -webkit-box, but the layout guard rejects both, which makes them usable only on a leaf whose child is literal text. And although Satori's presets set a default display: flex for divs, the guard reads the inline style prop rather than the computed value — so "display defaults to flex" and "you must write display flex explicitly" are both true at once. That contradiction is why this error burns an hour.

The rest of the fence, from the README:

LimitConsequence
No display: gridFlexbox-only layout, even for a card that's obviously a grid
Fonts must be passed as ArrayBuffer/BufferNo system fonts, no @font-face, no web-font URLs; WOFF2 unsupported (TTF/OTF/WOFF only)
No z-indexPaint order is document order — reorder the JSX
No calc(), no 3D transformsPrecompute the value
No kerning, ligatures, or OpenType features; no RTLRules out some brand typography entirely
PNG output onlyNo JPEG or WebP (satori#235, open)

There's also a version-skew trap worth knowing about: next/og bundles satori 0.25.0, while satori itself is at 0.29.0. WebP <img> support landed in 0.29.0, so feeding a WebP data URL to ImageResponse fails with a minified u2 is not iterable rather than anything resembling "unsupported format." Convert source images to PNG before you embed them.

Performance is genuinely good. Measured end-to-end through the real @vercel/og pipeline on an M4 Pro (1200×630, gradient plus three text blocks): p50 11.3 ms, p95 15.7 ms — but 513 ms on the very first call, which is WASM instantiation, not a serverless cold start. Any platform that recycles the isolate per request pays that 513 ms every single time. Satori's own layout step is ~2 ms; rasterization is essentially the whole bill.

Cache the image, or the crawlers will eat it

Generate each card once and serve it from cache. An OG image URL is fetched by crawlers far more often than by humans — nobody loads your card deliberately, but every paste of the link into every channel makes a machine go get it. Bill yourself once per image, not once per share.

Slack alone uses two distinct clients for this — Slackbot-LinkExpanding for the metadata and Slack-ImgProxy for the picture — and Slack documents a roughly 30-minute global cache for the link-expanding half only; the image proxy's TTL isn't published.

Next.js actively works against you here. The standalone @vercel/og sets cache-control: public, immutable, no-transform, max-age=31536000 on its response. Next.js's next/og throws that away — it builds its own Headers object with public, max-age=0, must-revalidate in production. Both strings ship in the same node_modules install: max-age=31536000 in dist/compiled/@vercel/og/index.node.js, and public, max-age=0, must-revalidate in dist/server/og/image-response.js (checked on Next 16.2.10 and 16.3.0-preview). So a hand-rolled app/api/og/route.tsx re-renders on essentially every hit unless you pass headers yourself — which is why the example above does.

The cheapest fix is to not have a runtime endpoint at all. Next's opengraph-image.tsx file convention is statically optimized — generated at build time and cached — unless it touches a request-time API. Use the file convention with generateImageMetadata, and the image becomes a build artifact rather than a function invocation. Keep the route handler only for cards whose content genuinely isn't known at build time.

When you actually need a real browser

Satori's limits are a design constraint, not a bug, and sometimes the design wins: charts drawn with a JS library, a card that has to look pixel-identical to a page you already built, anything needing grid or real typography. Then you render an HTML template page in headless Chromium and screenshot the element.

The cost is infrastructure, and it's specific. @sparticuz/chromium — the standard Lambda build — ships a Chromium binary of 130.62 MiB, compressed to 33.21 MiB with Brotli, and its README recommends at least 512 MB of RAM, with 1600 MB or more preferred. That's before your own code. Cold starts are the well-known pain; the commonly quoted "2–5 seconds" figures are secondary and I couldn't verify them against a primary source, so treat them as directional. What's not in dispute is the shape: a two-order-of-magnitude larger deployment artifact, and a rasterizer whose warm path is still slower than the 11 ms the whole Satori pipeline takes.

Run it as a long-lived service rather than a per-request function if you go this way. Keeping one browser process warm and reusing contexts turns the cold start from a per-request tax into a deploy-time one.

Or pay someone else to hold the browser

Hosted screenshot APIs are the same headless browser with the ops removed. Published entry pricing as of August 2026:

ServiceFree tierEntry planEffective cost per render
ApiFlash100/mo$7/mo → 1,000~$0.0018–$0.007
ScreenshotOne100/mo$17/mo → 2,000$0.004–$0.009
Urlboxnone$19/mo → 2,000~$0.0066–$0.0098
Browserless1,000 units$25/mo → 20,000 unitsbilled per 30s of browser time, not per render

Urlbox is explicit that it doesn't offer a free plan — "we primarily serve revenue-generating businesses" — which is a fair signal about who each tier is for. Browserless bills time rather than renders, so it's the wrong shape for OG cards specifically and the right shape if you're already doing general automation.

At these rates the arithmetic is unromantic: 800 pages regenerated on every deploy, at $0.006, is under $5 per deploy — trivial once, ruinous if an uncached endpoint lets crawlers drive the render count. Which loops back to the caching point.

Let the URL do the cache-busting

Meta's crawler documentation states the rule plainly: "Images are cached based on the URL and won't be updated unless the URL changes." That's usually quoted as a complaint. For generated images it's a feature — put a short content hash in the URL, derived from the exact inputs the card renders:

/og/cors-explained-9f2ab1c4.png

Edit the title, the hash changes, the URL changes, and every platform that caches by URL fetches the new card without anyone touching a debugger tool. The old URL can keep serving its old image forever with an immutable one-year Cache-Control, because it is genuinely immutable — that's the same trick your bundler already plays on JS filenames, applied to social cards.

Pick the renderer that matches your design constraints, then spend the remaining effort on making the image a build artifact with a content-addressed URL. The generation is the easy half; the caching is what decides whether this costs you nothing or costs you a bill.

Cover photo by Angela Roma on Pexels.

References

Primary documentation and specifications checked when this article was last updated.

SEOWeb DevelopmentNext.js

Related articles

All articles