GUIDE

Dynamic OG Images in Next.js: @vercel/og, Satori, and When to Use a Dedicated Tool

How Next.js actually generates a dynamic og:image at request time with @vercel/og and Satori, where that approach breaks down at scale, and when a managed renderer is the better call.

August 16, 20267 min read

If you're building on Next.js and want a unique og:image per blog post, product page, or user profile, the built-in path is the ImageResponse API, commonly reached through the @vercel/og package or Next.js's own opengraph-image.tsx file convention. It's a genuinely clever piece of engineering — JSX in, PNG out, computed at request time. Understanding what it's actually doing under the hood is the difference between using dynamic og image nextjs generation well and hitting a wall with it three months in.

What @vercel/og and ImageResponse actually do

ImageResponse takes a subset of JSX and CSS — flexbox layouts, text, background colors, images referenced by URL or as data — and renders it to a PNG at request time, inside an Edge Runtime function. The convention in the App Router is to drop an opengraph-image.tsx file in a route segment; Next.js wires it up automatically so that visiting a page's opengraph-image path returns a rendered image, and the framework injects the correct og:image meta tag pointing at it. You write React-shaped markup describing a card layout, and get back image bytes, all without touching a canvas API or an image-editing library directly.

Satori is the engine underneath

@vercel/og is a thin wrapper around Satori, which is the library actually doing the work of turning JSX and CSS into an SVG. Satori implements a real (but constrained) subset of the CSS layout algorithm — primarily flexbox — in JavaScript, without a browser, without a DOM, and without a rendering engine like WebKit or Chromium behind it. That's the key technical fact: Satori isn't taking a screenshot of a rendered webpage the way a headless-browser screenshot service would. It's parsing your JSX tree and CSS properties directly and computing layout itself, then emitting SVG markup, which @vercel/og then rasterizes to a PNG (typically via resvg, a Rust-based SVG renderer, running in the Edge Runtime or a Node function).

That design is exactly why it's fast — no browser to boot, no page to load, no JavaScript to execute inside a sandboxed tab. It's also exactly why it's constrained. Because Satori reimplements layout rather than delegating to a real browser engine, it only supports a subset of CSS: flexbox works, grid does not; many text and box properties work, but things like CSS transforms, filters, or advanced selectors generally don't. Custom fonts have to be explicitly loaded and passed in as font data — you cannot just reference a Google Fonts URL and expect the system font stack to resolve it, because there is no browser font-loading mechanism involved.

Where this approach starts to strain

For a single template rendering plain text and a background color, ImageResponse is close to ideal — it's fast, it ships with your app, and there's no external service in the loop. The friction shows up as requirements grow:

  • Design complexity — anything needing CSS grid, gradients with multiple stops in certain configurations, real shadows, or transforms either needs a workaround or won't render as designed. You end up debugging Satori's supported CSS subset instead of writing normal CSS.
  • Non-engineers editing templates — ImageResponse templates live in your codebase as JSX. A marketer who wants to change a headline font size or move a logo needs a pull request, not a settings panel.
  • Caching and invalidation — Edge Runtime functions compute on every request unless you add your own caching layer (headers, a CDN in front, or Next.js's fetch/data caching where applicable). Getting an immutable, versioned image URL — so old shares of a page never break even after you redesign the template — is something you have to build yourself, not something ImageResponse gives you.
  • Multi-platform correctness — different platforms expect different aspect ratios and have different crop and file-size behavior. Producing more than one variant per page (say, a square version for one platform and a 1200x630 for others) means writing and maintaining more than one template.
  • Scale across hundreds of pages — a marketplace or content site with thousands of listings hitting an Edge Function on every social crawler request works, but debugging a rendering regression across that many pages, or rolling back a bad template change safely, is entirely on you.

The versioning problem specifically

One thing ImageResponse doesn't give you out of the box is what happens when you change a template. If you edit your opengraph-image.tsx file and redeploy, every previously shared link that pointed at that dynamic image now renders differently — because the image URL is the same, it's the code behind it that changed. That's usually fine, but it means you can't rely on the URL itself as a stable snapshot of what an image looked like at share time, which matters if you're tracking historical performance or debugging a report of "the image looked different when I shared it."

When to hand-roll it vs. use a dedicated tool

Hand-rolling with ImageResponse makes sense when you have a small number of templates, engineers are the ones maintaining them, the layouts fit comfortably inside Satori's CSS subset, and you don't need non-technical people editing designs. It's genuinely the right default for a solo project or a small marketing site with one or two dynamic templates.

A dedicated rendering tool earns its place once any of the following are true: you want a visual template editor so someone outside engineering can update copy or swap a layout without a deploy; you need immutable, versioned render URLs so old shares stay stable even as templates evolve; you're generating images across enough pages that caching, CDN behavior, and platform-specific variants become their own maintenance burden; or you want render analytics and audit tooling (catching a page whose og:image silently broke) without building that instrumentation yourself.

A note on runtime and cold starts

ImageResponse is typically deployed on the Edge Runtime rather than the Node.js runtime, and that choice matters for how it behaves under real traffic. Edge functions generally have faster cold starts than traditional serverless Node functions, which is part of why request-time image generation feels responsive even without a warm process sitting around waiting for requests. But the Edge Runtime also has a narrower set of Node APIs available — no arbitrary filesystem access, restrictions on certain native modules — which occasionally matters if a template needs to pull font files or images from somewhere that assumes a standard Node environment. Most straightforward ImageResponse templates never hit this wall, but it's worth knowing it exists before architecting something that leans on filesystem access at render time.

Testing templates before they ship

Because ImageResponse output depends on Satori's specific CSS subset rather than standard browser rendering, a template that looks correct in a component preview using normal browser CSS can render differently once it actually goes through Satori's layout engine. The safest way to catch this is to preview the actual rendered output of the opengraph-image route directly — hitting the route in a browser and looking at the real PNG — rather than trusting how the JSX looks in a normal component render, since the two rendering paths aren't using the same layout engine at all. Teams that skip this step tend to discover the gap in production, when a social platform's crawler renders something subtly different from what was expected in development.

Where useopengraph fits

useopengraph is built for exactly the point where ImageResponse starts to strain. Templates are edited visually with variables for text, image, and color, so non-engineers can update a layout without touching JSX. Every published template gets a versioned, immutable render URL under /api/render/{templateId}/v{version}/... — publishing a new version never changes what old, already-shared URLs render, which solves the versioning gap ImageResponse leaves open. And because it's a hosted API and MCP server rather than code running in your app, generating images for a marketplace with thousands of listing pages, or letting an AI agent trigger a render call directly, doesn't add Edge Function load or caching logic to your own codebase at all.

Stop paying per seat
for a usage-shaped problem.

Unlimited teammates, one usage pool. Start free with the scanner — no card required.