· Tutorials · By Simon Chiu

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:

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, not renderToString. 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.ready after load. With display=block on the Google Fonts URL, the load event 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