React Component to Image: Static Markup vs Satori
Turn a React component into a PNG with renderToStaticMarkup and a browser, or with satori. 24 probes show which React and CSS features each path keeps.
To turn a React component into an image, render it to an HTML string with renderToStaticMarkup(), put that string in a full HTML document with its CSS, and screenshot the document in a real browser. The other popular path, satori, skips the browser and draws an SVG from your JSX directly. That is lighter, but it is not React: it does not run hooks, and it reads only part of CSS. This post measures both paths on the same component and gives you a feature table so you can pick one before you design your templates.
It is part of the HTML to image API cluster. If your question is "satori or Chrome for Open Graph cards" in general, the satori vs Puppeteer comparison covers fonts and six templates. This page is about React itself: what survives the trip from component to pixels.
Tested on 2026-10-18 with Node 22.18.0 on macOS 27.0.1 (Apple M1 Pro):
[email protected],[email protected],[email protected](Chrome for Testing 155.0.8059.79),[email protected],@resvg/[email protected],[email protected].
Two paths from JSX to pixels
| Static markup + browser | Satori + resvg | |
|---|---|---|
| Who runs your component | React (react-dom/server) |
Satori calls your function itself |
| Who does layout | Chromium | Satori's own flexbox engine (Yoga) |
| Output | Any format Chrome can write | SVG, then PNG via resvg |
| Needs Chrome | Yes, locally or through an API | No |
| Runs on edge runtimes | Only through an API | Yes |
| Fonts | Anything the page loads | Only font files you pass in |
The first path has one extra step (a browser), but every line of React and CSS behaves the way it does in your app. The second has no browser, so it runs anywhere Node or WebAssembly runs, at the cost of a different rule set.
The test component
One component drives every measurement on this page: a release card with a product name, a version and three highlights. It uses a stylesheet with a CSS variable, a ::before pseudo-element and a grid list, because those are the things real card templates use.
// ReleaseCard.tsx export type ReleaseCardProps = { product: string; version: string; highlights: string[]; date: string; }; export const styles = ` :root { --ink: #0b1220; --paper: #f6f3ee; --accent: #c2410c; } * { box-sizing: border-box; } html, body { margin: 0; } .card { width: 1200px; height: 630px; padding: 72px 80px; display: flex; flex-direction: column; background: var(--paper); color: var(--ink); font-family: Inter, system-ui, sans-serif; } .eyebrow { font-size: 28px; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); } .title { font-size: 88px; font-weight: 700; margin: 12px 0 32px; line-height: 1; } .list { margin: 0; padding: 0; list-style: none; display: grid; gap: 14px; } .list li { font-size: 34px; display: flex; gap: 16px; } .list li::before { content: ''; flex: none; width: 14px; height: 14px; margin-top: 14px; background: var(--accent); border-radius: 50%; } .foot { margin-top: auto; font-size: 26px; opacity: .7; } `; export function ReleaseCard({ product, version, highlights, date }: ReleaseCardProps) { return ( <div className="card"> <div className="eyebrow">{product} release</div> <h1 className="title">v{version}</h1> <ul className="list"> {highlights.slice(0, 3).map((h) => <li key={h}>{h}</li>)} </ul> <div className="foot">{date}</div> </div> ); } export function Document({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <head> <meta charSet="utf-8" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=block" /> <style dangerouslySetInnerHTML={{ __html: styles }} /> </head> <body>{children}</body> </html> ); }
Document is a plain component too. Keeping the <html> shell in JSX means the stylesheet, fonts and markup are versioned together.
Path 1: renderToStaticMarkup and a browser
Install the pieces with pinned versions:
npm i [email protected] [email protected] [email protected] npm i -D [email protected] @types/react
Then render the component, prepend a doctype, and screenshot it:
// render-local.tsx (run: npx tsx render-local.tsx) import { writeFile } from 'node:fs/promises'; import { renderToStaticMarkup } from 'react-dom/server'; import puppeteer from 'puppeteer'; import { ReleaseCard, Document, type ReleaseCardProps } from './ReleaseCard.tsx'; export function toHtml(props: ReleaseCardProps) { // React escapes every string prop, so user text cannot add tags. return '<!doctype html>' + renderToStaticMarkup(<Document><ReleaseCard {...props} /></Document>); } const html = toHtml({ product: 'Acme CLI', version: '4.2.0', highlights: ['Parallel uploads', 'A <script> tag in a title stays text', 'Config files in TOML'], date: '18 October 2026', }); const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setViewport({ width: 1200, height: 630, deviceScaleFactor: 2 }); await page.setContent(html, { waitUntil: 'load' }); await page.evaluate(() => document.fonts.ready); await writeFile('release-card.png', await page.screenshot({ type: 'png' })); console.log('wrote release-card.png,', html.length, 'bytes of HTML'); } finally { await browser.close(); }
Three details carry most of the weight:
renderToStaticMarkup, notrenderToString. The static version leaves out hydration markers. The page never hydrates, so they are noise.- The doctype. React does not emit
<!doctype html>. Without it, Chrome renders in quirks mode, and box sizes and line heights shift a little. document.fonts.readyafterload. Withdisplay=blockon the Google Fonts URL, theloadevent normally covers the font, but the extra wait costs almost nothing and protects you when a font is declared late.
React escapes text and attribute values for you, so the <script> string in the highlights shows up as text in the PNG. That protection ends where you use dangerouslySetInnerHTML. Only pass trusted strings into it, such as your own stylesheet.
Path 2: satori and resvg
Satori takes a JSX element and font data, and returns SVG. resvg turns the SVG into PNG.
// render-satori.tsx import { readFile, writeFile } from 'node:fs/promises'; import satori from 'satori'; import { Resvg } from '@resvg/resvg-js'; import { ReleaseCard, styles } from './ReleaseCard.tsx'; const inter = await readFile('Inter-SemiBold.ttf'); // satori needs the font bytes const svg = await satori( <div style={{ display: 'flex' }}> <style>{styles}</style> <ReleaseCard product="Acme CLI" version="4.2.0" date="18 October 2026" highlights={['Parallel uploads', 'Smaller binaries', 'Config files in TOML']} /> </div>, { width: 1200, height: 630, fonts: [{ name: 'Inter', data: inter, weight: 600, style: 'normal' }] }, ); await writeFile('release-card-satori.png', new Resvg(svg).render().asPng());
Satori 0.62.0 read our <style> element, the class names, the :root variable and the grid list, which older satori releases did not do. The two PNGs of the release card matched closely; the visible difference was text anti-aliasing. So the CSS gap is smaller than many older articles say. The React gap is not.
What each path keeps: 24 probes
Each probe is a 200 x 100 component that paints a red area only if the feature works. A script renders it both ways and reads the pixels at fixed points, so "works" means "the expected pixels are red", not "it looked fine to us". We checked the suspicious results again by hand.
| Feature | Static markup + Chrome | Satori 0.62.0 |
|---|---|---|
style={{...}} object |
Yes | Yes |
className + <style> element |
Yes | Yes |
:nth-child() selector |
Yes | Yes |
var() declared on :root |
Yes | Yes |
calc() |
Yes | Yes |
Flex gap |
Yes | Yes |
display: grid columns |
Yes | Yes |
aspect-ratio |
Yes | Yes |
transform: rotate() |
Yes | Yes |
filter: invert() |
Yes | Yes |
oklch() colour |
Yes | Yes |
color-mix() |
Yes | Yes |
z-index stacking |
Yes | Yes |
border-radius: 50% |
Yes | Yes |
mask-image gradient |
Yes | Yes |
position: fixed |
Yes | Yes |
<img> with a data URI |
Yes | Yes |
backdrop-filter |
Yes | No |
useState initial value |
Yes | Throws (invalid hook call) |
useContext default value |
Yes | Throws (invalid hook call) |
State set in useEffect |
No (effects never run) | Throws (invalid hook call) |
memo() component |
Yes | Draws nothing, no error |
forwardRef() component |
Yes | Yes |
dangerouslySetInnerHTML |
Yes | Throws ("not supported") |
The pattern is clear. Satori now covers most layout CSS that a card needs. What it cannot do is behave like React. It calls your function components without a React dispatcher, so any hook throws, and wrapped components such as memo() can vanish silently. If your card components come from an app's design system, they almost certainly use hooks or context, and only the static markup path will render them unchanged.
Data loading: effects do not run, async components need prerender
The one "No" in the Chrome column is important. renderToStaticMarkup renders once and returns. useEffect never runs, so anything that an effect fetches or sets is missing from the image. Load data before you render and pass it in as props.
React 19 server components can be async. We tested what each server API does with one inside a Suspense boundary:
// async-test.tsx import { Suspense } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { prerender } from 'react-dom/static'; async function Stars({ repo }: { repo: string }) { const n = await new Promise<number>((r) => setTimeout(() => r(1234), 50)); // stands in for fetch() return <span className="stars">{n} stars for {repo}</span>; } const App = () => ( <div><Suspense fallback={<span>loading…</span>}><Stars repo="acme/web" /></Suspense></div> ); console.log(renderToStaticMarkup(<App />)); const { prelude } = await prerender(<App />); console.log(await new Response(prelude).text());
Output:
<div><span>loading…</span></div> <div><!--$--><span class="stars">1234<!-- --> stars for <!-- -->acme/web</span><!--/$--></div>
renderToStaticMarkup printed the fallback, and with no error. If you screenshot that, your card says "loading…". prerender from react-dom/static waited for the data. The comment nodes it adds have no effect on the image.
Measured: time and bytes
Same card, same machine, 20 timed runs after 2 warm-up runs, repeated twice. The font was embedded as a data: URI for the timing runs so that network time did not count.
| Step | Median | p95 | Output |
|---|---|---|---|
renderToStaticMarkup alone |
0.23 ms | not measured | 1,324 bytes of HTML (with the Google Fonts link) |
| Satori + resvg, per image | 162 to 166 ms | 179 to 195 ms | 28,803-byte PNG |
| Puppeteer launch + first page | 620 to 725 ms | one-off | none |
Puppeteer setContent + screenshot, warm page |
57 to 66 ms | 80 to 91 ms | 35,622-byte PNG |
A warm browser page was faster per image than satori in our test. Satori's advantage is that it has no 600 to 700 ms launch and no Chrome binary. For a request-time card on an edge function, that matters more than the per-image time. For a worker that renders hundreds of images, a long-lived browser wins.
The same component through an API
If you want the static markup path without running Chrome, send the HTML string to an API. This uses the Node SDK and the toHtml function from above:
// render-api.tsx import { writeFile } from 'node:fs/promises'; import { Client, TakeOptions } from 'renderscreenshot'; import { toHtml } from './to-html.tsx'; // the toHtml() function from render-local.tsx const client = new Client(process.env.RS_API_KEY!); const image = await client.take( TakeOptions.html(toHtml({ product: 'Acme CLI', version: '4.2.0', highlights: ['Parallel uploads', 'Smaller binaries', 'Config files in TOML'], date: '18 October 2026', })) .width(1200) .height(630) .scale(2) .format('png') ); await writeFile('release-card.png', image);
The request is a POST with the HTML in the body, so the size of the template is not limited by URL length. The Google Fonts link in Document works because the renderer loads it like any page would. Relative asset paths do not: an HTML string has no address, so use absolute URLs or html_base_url as described in the HTML to image guide. This code was not run against the live API for this post. The SDK calls match the documented Client and TakeOptions methods.
Troubleshooting
| Symptom | Path | Cause | Fix |
|---|---|---|---|
| Image shows a loading state | Static markup | renderToStaticMarkup rendered a Suspense fallback |
Fetch data first, or use prerender from react-dom/static |
Value from useEffect missing |
Static markup | Effects never run on the server | Compute the value before render and pass it as a prop |
Invalid hook call |
Satori | Satori calls components without React | Remove hooks from card components, or use the browser path |
| A component disappears | Satori | It is wrapped in memo() |
Export the unwrapped function for image rendering |
dangerouslySetInnerHTML property is not supported |
Satori | Not implemented | Build the element tree in JSX |
| Slightly different line heights than the app | Static markup | No doctype, so quirks mode | Prepend <!doctype html> |
| Fallback font in the PNG | Static markup | Captured before the font loaded | display=block on the font URL and wait for document.fonts.ready |
Raw <script> text appears |
Static markup | Working as intended: React escaped it | Nothing to fix; never move user text into dangerouslySetInnerHTML |
| Text is blurry on phones | Both | 1x output | deviceScaleFactor: 2 in Puppeteer, .scale(2) in the SDK |
| Each image takes about a second | Static markup | A new browser per image | Launch once and reuse it, or use an API |
Which path to pick
- The component already exists in your app and uses hooks or context: static markup plus a browser. Nothing else renders it unchanged.
- You write card components only for images, on an edge runtime: satori. Design them without hooks and test them with satori from the start.
- You want the browser path but not the browser: send the static markup to an API. Create a free account for 150 credits a month and send the release card as your first request.
For the same idea with utility CSS, see Tailwind HTML to image. For Next.js routes that serve the result as an og:image, see Next.js OG images.
Related in this topic
- HTML to Image API: Render HTML and CSS to PNG Send HTML and CSS, get a PNG, JPEG, WebP or PDF back from real Chrome. Base URLs for assets, web fonts, transparent backgrounds and signed URLs.
- Best HTML to Image APIs (2026): 9 Options Compared The best HTML to image APIs compared: rendering engine, ready signals, fonts, delivery, public links and prices at three volumes, from each vendor's own docs.
- html2canvas Alternative: Render the DOM in Real Chromium We tested html2canvas 1.4.1 against real Chromium on 14 CSS features. See what it drops, when it is still the right tool, and how to move capture server-side.
- Convert a Webpage to an Image: 5 Methods, Measured Convert a webpage to a PNG, JPEG or WebP five ways, from a DevTools command to an API. Effort, cost and timings for each, plus the settings that matter.
- HTML to Image in Node.js: 4 Ways, Same 6 Templates HTML to image in Node.js with Puppeteer, node-html-to-image, satori and an API, run on the same six templates. Measured times, what broke, and code that works.
- HTML to Image in Python: imgkit, html2image, Playwright Convert HTML to an image in Python with html2image, Playwright or an API, compared on five templates with timings and renders, and why imgkit is a dead end.