What Satori actually is

Satori is a library by Vercel that converts React/JSX trees to SVG without running a browser. resvg-js can then rasterise that SVG to PNG. The output is sharp, the bundle is tiny (<5 MB total), and it runs serverlessly without any system dependencies.

Vercel built it to power next/og — automatically generating Open Graph images in Next.js API routes. For that specific use case, it's genuinely excellent: structured card layouts, a handful of fonts, simple text and flexbox, zero infrastructure overhead.

The constraints become blockers the moment you take it outside that narrow sweet spot.

Where Satori stops working

CSS limitations

Satori implements its own layout engine, not a browser. It supports flexbox and a limited set of CSS properties. What it explicitly does not support:

The silent failure mode: Satori doesn't throw when it encounters unsupported CSS. It silently skips or approximates. Your template can look correct in 80% of cases and break quietly in the remaining 20% — especially with complex selectors or inherited properties.

JSX requirement

Satori takes a React element tree, not an HTML string. That means:

For a Next.js app where your team already writes JSX all day, this is zero friction. For a backend service in Express, a cron job in plain Node, or any automation tool that generates images from data — the JSX requirement is a meaningful adoption cost.

Side-by-side: Satori JSX vs plain HTML

Here's the same OG image card implemented in both approaches:

Satori (JSX required)
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFileSync } from 'fs';

const font = readFileSync('./Inter-Bold.ttf');

const svg = await satori(
  <div style={{
    display: 'flex',
    flexDirection: 'column',
    width: '100%',
    height: '100%',
    background: '#0a0a0b',
    padding: '48px',
    // ❌ CSS vars don't work here
    // color: 'var(--text)',
    color: '#e8e8ed',
  }}>
    <div style={{
      // ❌ No grid — forced to use flex
      display: 'flex',
      flexWrap: 'wrap',
      gap: '16px',
    }}>
      <span style={{
        // ❌ No ::before pseudo-element
        // ❌ No z-index layering
        fontSize: 48,
        fontWeight: 700,
      }}>
        {title}
      </span>
    </div>
  </div>,
  {
    width: 1200,
    height: 630,
    fonts: [{ name: 'Inter', data: font, weight: 700 }],
  }
);

const png = new Resvg(svg).render().asPng();
RenderPix (plain HTML)
const res = await fetch(
  'https://renderpix.dev/v1/render',
  {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.RENDERPIX_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html: `
        <style>
          :root { --text: #e8e8ed; }
          /* ✅ CSS vars work */
          /* ✅ Grid works */
          /* ✅ z-index works */
          /* ✅ ::before works */
          .card {
            display: grid;
            grid-template-columns: 1fr auto;
            gap: 16px;
            color: var(--text);
          }
          .title::before {
            content: '→ ';
            color: #22d3ee;
          }
        </style>
        <div class="card">
          <span class="title">${title}</span>
        </div>
      `,
      width: 1200,
      height: 630,
      format: 'png',
    }),
  }
);
const { image } = await res.json();
// image = base64 PNG

The HTML version accepts any CSS your browser understands. It's the same code your frontend already uses — no translation layer, no JSX rewrite, no font file management.

Feature comparison

Feature Satori + resvg Self-hosted Puppeteer RenderPix
Full CSS support Subset only Yes Yes
CSS Grid No Yes Yes
CSS custom properties No Yes Yes
z-index / positioning No Yes Yes
Pseudo-elements No Yes Yes
JSX required Yes No No
Serverless-safe Yes No Yes
Binary / bundle size <5 MB ~300 MB 0 MB
Cold start <50 ms 1–3 s ~200 ms
Template variables (vars) Manual string replace Manual string replace Built-in
Batch rendering Loop yourself Loop yourself Native /v1/batch
Native n8n node No No Yes
Maintenance overhead Low High None

The n8n / automation angle

If you're building image generation into a no-code or low-code pipeline — n8n, Make, Zapier — Satori is effectively a non-starter. There's no official integration, and wiring JSX compilation into an n8n Code node is possible but painful.

RenderPix has a native n8n node. The workflow looks like this:

  1. Trigger — Webhook, Google Sheets row, form submission, LMS event.
  2. RenderPix node — Your HTML template with {{name}}, {{title}}, {{date}} placeholders. The node accepts a vars object and returns a base64 PNG.
  3. Output — Email attachment, Google Drive upload, Slack message, or any downstream node.

No code. No JSX. No binary dependencies. The same workflow runs identically in Make and Zapier via the HTTP Request module.

Template variables are built into the RenderPix API. Pass "vars": { "name": "Jane", "course": "Advanced n8n" } and write {{name}} in your HTML. No string interpolation needed in your workflow code.

Migration: from Satori to RenderPix

If you're hitting Satori's CSS limits and want to migrate, the process is straightforward because you're moving toward real HTML — not away from it:

  1. Convert your JSX template to HTML/CSS. If you were forced to avoid grid or CSS variables in Satori, you can add them back now.
  2. Replace satori() + resvg with a single fetch call. Pass the HTML string and a vars object for dynamic fields.
  3. Remove the font loading boilerplate. Load fonts with @font-face in your CSS — any URL Chromium can reach works, including Google Fonts.
// Before — Satori
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
const font = readFileSync('./Inter-Bold.ttf');
const svg = await satori(<Card name={name} />, {
  width: 1200, height: 630,
  fonts: [{ name: 'Inter', data: font, weight: 700 }],
});
const png = new Resvg(svg).render().asPng();

// After — RenderPix (drop-in)
const res = await fetch('https://renderpix.dev/v1/render', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.RENDERPIX_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    html: cardTemplate,   // your HTML string, no JSX needed
    vars: { name },       // dynamic fields injected server-side
    width: 1200, height: 630, format: 'png',
  }),
});
const { image } = await res.json(); // base64 PNG

When to stay on Satori

This post isn't trying to talk you out of Satori. There are situations where it's genuinely the best fit:

Outside these cases — particularly if you have existing HTML/CSS templates, non-JSX stacks, complex layouts, or need to plug into automation workflows — the trade-off tilts toward a render API.

Summary

Full HTML/CSS rendering — no JSX, no binary

Send any HTML. Get back a pixel-perfect PNG. Free tier included, no credit card required.

Related Articles
Puppeteer Alternatives in 2026 — Faster, Cheaper HTML to Image OG Image Generators in 2026 — Open Source vs API HTML to image in Node.js — without Puppeteer