Disclosure: RenderScreenshot is our product, and it competes with @vercel/og for one job: making Open Graph images. @vercel/og facts on this page come from Vercel's OG image docs, checked on 2026-10-18. The tests below ran on our own laptop against the open-source engine. We did not benchmark Vercel's hosted functions or our hosted API for this page.
@vercel/og turns JSX into a PNG with Satori (JSX to SVG) and resvg (SVG to PNG). It is built into the Next.js App Router as next/og, starts fast and costs nothing beyond your Vercel functions. Its limit is CSS: Satori implements a layout engine of its own, not a browser. RenderScreenshot takes the other route. Your card is an HTML page, Chromium renders it, and the URL in og:image is a signed screenshot URL. This page shows where that difference matters, with templates we rendered both ways.
TL;DR
| Stay with @vercel/og if | Move to RenderScreenshot if |
|---|---|
| Your cards are simple flexbox layouts with one or two fonts | You want CSS grid, filters, blend modes or your site's real stylesheet |
| You already deploy on Vercel and want zero extra accounts | You deploy elsewhere, or several apps need the same card service |
| You need the image generated next to your data with no third party | You want the card to reuse your site's components and fonts, as HTML |
| Your bundle stays under the 500 KB limit, fonts included | You need several weights of a variable font, CJK fonts or large images |
What we tested
We wrote six 1200×630 cards twice: once as a Satori element tree with inline styles, once as HTML. We rendered the trees with [email protected] and @resvg/[email protected], and the HTML with the Chromium build bundled with [email protected]. Both ran in Node 22.18 on an Apple M1 Pro, on 2026-10-18, with Arial loaded from the system as the only font given to Satori.
| Template | Satori result | Chromium result |
|---|---|---|
| Flexbox column: section, title, byline | Rendered as designed | Rendered as designed |
CSS grid, 2fr 1fr columns |
No error, wrong layout. The sidebar landed about 100 px further right than in Chromium; the columns did not follow the 2fr 1fr split |
Columns at the expected 2:1 split |
<style> element with a class selector |
Rendered as designed (this surprised us; older Satori write-ups say stylesheets are ignored) | Rendered as designed |
| Two-line clamp with ellipsis | Rendered as designed | Rendered as designed |
| Emoji and Japanese text, no extra fonts | No error, boxes in place of the rocket emoji and every Japanese character | Rendered with system fonts |
Translucent panel with backdrop-filter: blur() over a gradient |
No error, wrong colours. The panel came out opaque dark grey instead of a light translucent glass | Light translucent panel as designed |
Three of six templates came out wrong, and none of them raised an error or a warning. That is the practical risk with Satori: you find out from a preview in Slack, not from your logs.
Two notes to be fair to @vercel/og:
- The emoji and CJK result is about fonts, not about Satori's layout.
ImageResponsehas anemojioption and can fetch Google Fonts subsets for the characters you use, and we gave raw Satori neither. In Chromium, missing glyphs also turn into boxes on a server that lacks the fonts; our test machine had them. - Vercel's docs say plainly that
display: gridis not supported. The finding here is that it fails quietly.
Timings
The same run recorded render time for each card, 15 renders per template per engine, after the browser had started:
| Template | Satori + resvg p50 | Satori + resvg p95 | Chromium (warm page) p50 | Chromium p95 | PNG bytes, Satori / Chromium |
|---|---|---|---|---|---|
| Flexbox column | 226 ms | 1,744 ms | 49 ms | 248 ms | 33,114 / 46,440 |
| CSS grid | 216 ms | 531 ms | 35 ms | 166 ms | 30,795 / 41,801 |
| Stylesheet classes | 189 ms | 264 ms | 33 ms | 35 ms | 30,621 / 36,379 |
| Line clamp | 172 ms | 230 ms | 33 ms | 49 ms | 31,876 / 35,600 |
| Emoji and CJK | 164 ms | 207 ms | 16 ms | 61 ms | 12,290 / 23,298 |
| Backdrop blur | 198 ms | 245 ms | 117 ms | 122 ms | 55,618 / 255,370 |
Starting Chromium itself took 404 ms once, before the first card.
Read these numbers with their limits. Our script passes the font buffers to Satori on every call, the way a simple route handler does; caching parsed fonts would likely cut Satori's time. The first Satori call includes warm-up, which is why one p95 is high. Chromium timings assume a browser that is already running. None of this is Vercel's infrastructure or our API. What the numbers do show is that a real browser is not inherently the slow option for a 1200×630 card. The cost of Chromium is operational (keeping it running, installing fonts), and that is the part an API takes off your hands.
Option mapping
@vercel/og / next/og |
RenderScreenshot | Notes |
|---|---|---|
new ImageResponse(jsx, { width, height }) |
preset=og_card (1200×630) or width and height |
See presets |
JSX with inline style objects |
An HTML route on your site, any CSS | Flexbox styles carry over as they are |
fonts: [{ name, data, weight }] |
@font-face or a Google Fonts <link> in the template |
No bundle-size limit on fonts |
emoji: 'twemoji' and friends |
An emoji web font in the template | See the emoji guide |
| Output is always PNG | format=png, jpeg or webp |
|
Cache headers set by ImageResponse |
cache_ttl up to 30 days; cache_available=true for 0-credit repeats |
See caching |
opengraph-image.tsx file convention |
generateMetadata returning a signed URL |
Example below |
| Encrypting parameters (Vercel guide) | Signed URLs: every parameter is covered by an HMAC | See signed URLs |
Price at three volumes
@vercel/og has no price of its own. Each OG route request on Vercel is a function invocation billed on your Vercel plan, and cached images are served from Vercel's CDN. The cost therefore depends on your plan, your cache hit rate and your function duration, and we did not measure it. Check Vercel's pricing page with your own usage numbers.
For RenderScreenshot, one fresh render is one credit, a reused render is 0 credits, and a failed render is free:
| Fresh cards per month | Plan | Price |
|---|---|---|
| 1,000 | Starter (2,500 credits) | $10/month |
| 10,000 | Growth (12,000 credits) | $40/month |
| 100,000 | More than Scale's 60,000 credits ($100/month) | Contact us |
Prices from our pricing page. "Fresh cards" means renders, not crawler fetches: with a 30-day cache, each card renders about once a month however often it is shared.
Migration
The migration has three steps, and you can do them one route at a time.
1. Turn the JSX into an HTML route. Keep the component; render it as a page at exactly 1200×630 instead of passing it to ImageResponse. Flexbox styles move over unchanged. Escape every text value that enters the HTML; React does this for you if the route renders with React.
2. Sign the screenshot URL in your metadata. Replace the opengraph-image.tsx file with a signed URL from generateMetadata. Put the final URL in the tag, not a route that redirects to it. The OG image tutorial has the full App Router code, including a weekly bucketed expiry that keeps URLs stable.
3. Compare old and new before you switch. This Node script downloads both cards for a list of slugs so you can review them side by side. It reads your old OG route and a signed RenderScreenshot URL for the new HTML route:
// compare-cards.mjs — node compare-cards.mjs hello-world pricing-update ... import { createHmac } from 'node:crypto'; import { mkdir, writeFile } from 'node:fs/promises'; const SITE = process.env.SITE ?? 'https://example.com'; const encode = (v) => encodeURIComponent(String(v)) .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()) .replace(/%20/g, '+'); function signedUrl(params) { const fields = { ...params, key_id: process.env.RS_PUBLIC_KEY_ID, expires: Math.floor(Date.now() / 1000) + 3600 }; const query = Object.entries(fields).map(([k, v]) => `${encode(k)}=${encode(v)}`).sort().join('&'); const sig = createHmac('sha256', process.env.RS_SECRET_KEY).update(query).digest('hex'); return `https://api.renderscreenshot.com/v1/screenshot?${query}&signature=${sig}`; } async function download(url, file) { const res = await fetch(url); if (!res.ok) throw new Error(`${res.status} for ${file}: ${await res.text()}`); await writeFile(file, Buffer.from(await res.arrayBuffer())); } await mkdir('cards', { recursive: true }); for (const slug of process.argv.slice(2)) { // Adjust to wherever your current OG image is served (copy it from a page's og:image tag). await download(`${SITE}/blog/${slug}/opengraph-image`, `cards/${slug}.vercel.png`); await download(signedUrl({ url: `${SITE}/og/${slug}`, preset: 'og_card' }), `cards/${slug}.rs.png`); console.log(`saved cards/${slug}.vercel.png and cards/${slug}.rs.png`); }
Each new card costs one credit. Run it on a handful of slugs with long titles, emoji and non-Latin text, since those are where the two engines differ most. The script fails with a non-zero exit code on the first error, so a broken signature does not go unnoticed.
If your backend is not Node, the signing step is short in any language. Python:
import hashlib, hmac from urllib.parse import quote_plus API = 'https://api.renderscreenshot.com/v1/screenshot' def sign(params, key_id, secret, expires): fields = {**params, 'key_id': key_id, 'expires': int(expires)} query = '&'.join(sorted(f'{quote_plus(str(k), safe="")}={quote_plus(str(v), safe="")}' for k, v in fields.items())) sig = hmac.new(secret.encode(), query.encode(), hashlib.sha256).hexdigest() return f'{API}?{query}&signature={sig}'
Ruby:
require 'cgi' require 'openssl' API = 'https://api.renderscreenshot.com/v1/screenshot' def sign(params, key_id:, secret:, expires:) fields = params.merge('key_id' => key_id, 'expires' => expires.to_i) query = fields.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.sort.join('&') "#{API}?#{query}&signature=#{OpenSSL::HMAC.hexdigest('SHA256', secret, query)}" end
Both functions reproduce the reference signature from our signed URL docs (tested 2026-10-18 with Python 3.15 and Ruby 4.0). The Node comparison script was not run against live services. Expiry must be a Unix time in seconds, at most 30 days ahead.
When to stay with @vercel/og
Stay if your cards are a title, a logo and a gradient in a flex column, and you are on Vercel already. That case is what Satori was built for, it needs no third party, and the image is generated next to your data. Stay too if sending your card URL to an outside renderer is a problem for your security review.
Move when you notice that you are designing around the renderer: avoiding grid, flattening effects, subsetting fonts to stay under 500 KB, or keeping a second set of card-only components. Those are the signs that a real browser would let the card be just another page. For the wider field, including template editors and other HTML renderers, see the best OG image generators, or start from the OG image API overview.
Get a free key and run the comparison script on five of your own slugs; 150 free credits a month cover it many times over.