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:
- CSS Grid —
display: grid,grid-template-columns,grid-areaare all unsupported. Everything must be laid out with flexbox. - z-index — layered elements, overlapping badges, positioned overlays: none of this works as expected.
- CSS custom properties (variables) —
var(--accent)is silently ignored. Your design token system doesn't carry over. - Pseudo-elements —
::before,::after,::placeholder: not rendered. - CSS transforms on arbitrary elements — limited support; complex
transformchains break. - box-shadow with spread — inset shadows and spread radius are unsupported.
- border-image, mask, clip-path — not implemented.
- calc() with mixed units — partially supported but brittle at edges.
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:
- Every template must be written as JSX — you can't pass existing HTML.
- Your project must have a JSX transform configured (Babel, SWC, or TypeScript with
jsxenabled). - Templates live in
.tsx/.jsxfiles and are compiled as code, not loaded as data. - Non-Next.js stacks (plain Express, Fastify, Python sidecar calling Node, n8n custom code) need explicit JSX wiring just to call Satori.
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:
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();
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:
- Trigger — Webhook, Google Sheets row, form submission, LMS event.
- RenderPix node — Your HTML template with
{{name}},{{title}},{{date}}placeholders. The node accepts avarsobject and returns a base64 PNG. - 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:
- 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.
- Replace
satori()+resvgwith a singlefetchcall. Pass the HTML string and avarsobject for dynamic fields. - Remove the font loading boilerplate. Load fonts with
@font-facein 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:
- You're in a Next.js project already using
next/og. The integration is seamless, the JSX is already there, and the CSS subset is usually enough for OG cards. - Your templates are simple flexbox cards with no complex CSS. If you're not hitting any of the limitations listed above, there's no reason to change.
- Zero external dependencies is a hard requirement. Satori runs entirely in-process. If calling an external API is blocked by your security policy or deployment constraints, Satori wins on that axis.
- You're rendering at very high volume (>1M images/month) and the per-render cost of an API would exceed the cost of owning a Chromium render fleet.
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
- Satori doesn't support CSS Grid,
z-index, custom properties, or pseudo-elements. Silently skips them. - It requires JSX, which adds setup cost for any non-Next.js Node.js stack.
- Migration to RenderPix means converting JSX back to HTML — usually a simplification, not a rewrite.
- Built-in
varssubstitution and a native n8n node make RenderPix significantly easier to integrate into automation workflows. - Satori remains the right call for simple OG cards in Next.js projects where its CSS subset is sufficient.
Full HTML/CSS rendering — no JSX, no binary
Send any HTML. Get back a pixel-perfect PNG. Free tier included, no credit card required.