Vercel OG Alternative: When Satori's CSS Runs Out

A @vercel/og alternative for cards that need full CSS. We rendered six templates in Satori and Chromium: what broke silently, timings, and a migration path.

By Simon Chiu · Updated

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. ImageResponse has an emoji option 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: grid is 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.

Frequently asked questions

Does Satori support CSS grid?
Vercel's docs say only flexbox and a subset of CSS are supported and that display: grid will not work. In our test with satori 0.62.0, a grid template rendered without any error, but the columns were laid out differently from Chromium. Check the output, not only the logs.
Is @vercel/og free?
The library is free and open source. On Vercel, an OG route runs as a function on your plan and is billed like any other function, and the generated image is cached on Vercel's CDN through the headers ImageResponse sets.
Can I keep my JSX components when I move off @vercel/og?
The JSX itself does not render outside Satori, but the same component can render to HTML with your normal framework route. Inline style objects become CSS; flexbox layouts carry over unchanged, and you can then use grid and other CSS that Satori lacks.
Is a screenshot API slower than Satori?
Not necessarily. On our laptop, a warm Chromium page captured our six cards faster than Satori plus resvg rendered them. A hosted API adds a network round trip and a browser start on a cache miss, so compare end to end on your own templates.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.