Satori: Generate Dynamic OG Images with JSX and CSS
How to use Satori to convert JSX+CSS into SVG/PNG og:images — standalone, in Cloudflare Workers, Next.js Edge, or any JavaScript runtime.
What is Satori?
Satori is an open-source library from Vercel that converts a JSX element tree with inline CSS styles into an SVG string — which can then be converted to PNG using a library like Sharp or Resvg. It implements a subset of CSS (flexbox layout, basic typography, gradients, borders) and supports custom fonts via ArrayBuffer.
Satori runs entirely in JavaScript with no native dependencies, making it ideal for edge runtimes like Cloudflare Workers, Deno Deploy, and Next.js Edge Functions — places where Puppeteer or Playwright can't run.
Install Satori
npm install satori # For PNG output (Node.js only): npm install sharp # Or for edge environments: npm install @resvg/resvg-wasm
Basic usage: SVG from JSX
Satori takes a JSX element (as a plain object — not React.createElement) and an options object with width, height, and font data. It returns a Promise that resolves to an SVG string.
import satori from 'satori';
import { readFileSync } from 'fs';
const fontData = readFileSync('./fonts/Inter-Bold.ttf');
const svg = await satori(
{
type: 'div',
props: {
style: {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: '#0a0a0a',
padding: '48px',
},
children: [
{
type: 'h1',
props: {
style: { fontSize: 56, color: '#fff', fontWeight: 700, margin: 0 },
children: 'Hello from Satori',
},
},
{
type: 'p',
props: {
style: { fontSize: 28, color: '#a1a1aa', marginTop: 16 },
children: 'Dynamic OG images with JSX',
},
},
],
},
},
{
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: fontData, weight: 700, style: 'normal' }],
}
);
console.log(svg); // <svg ...>...</svg>Convert SVG to PNG with Sharp
import sharp from 'sharp';
const png = await sharp(Buffer.from(svg)).png().toBuffer();
// Write to file or serve as HTTP response:
// fs.writeFileSync('./public/og/my-post.png', png);
// res.set('Content-Type', 'image/png').send(png);Satori in Next.js App Router (Edge Runtime)
Next.js 13+ ships @vercel/og which wraps Satori with a convenience API. The image route runs on the Edge Runtime, so it can be deployed globally with near-zero cold starts.
// app/og/route.tsx
import { ImageResponse } from 'next/og';
import type { NextRequest } from 'next/server';
export const runtime = 'edge';
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const title = searchParams.get('title') ?? 'Untitled';
return new ImageResponse(
(
<div
style={{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
background: '#0a0a0a',
padding: '64px',
justifyContent: 'flex-end',
}}
>
<p style={{ fontSize: 20, color: '#a855f7', margin: 0 }}>My Blog</p>
<h1 style={{ fontSize: 52, color: '#fff', margin: '12px 0 0', lineHeight: 1.2 }}>
{title}
</h1>
</div>
),
{ width: 1200, height: 630 }
);
}
// Use as: og:image = https://yourdomain.com/og?title=My+PostThen in your page metadata:
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
const ogUrl = `https://yourdomain.com/og?title=${encodeURIComponent(post.title)}`;
return {
title: post.title,
openGraph: {
images: [{ url: ogUrl, width: 1200, height: 630 }],
},
};
}Satori in Cloudflare Workers
In Cloudflare Workers, use @resvg/resvg-wasm for PNG conversion since Sharp requires native binaries. Load WASM once at module startup.
import satori from 'satori';
import { Resvg, initWasm } from '@resvg/resvg-wasm';
import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';
let wasmInit = false;
async function ensureWasm() {
if (!wasmInit) { await initWasm(resvgWasm); wasmInit = true; }
}
export default {
async fetch(request: Request) {
await ensureWasm();
const url = new URL(request.url);
const title = url.searchParams.get('title') ?? 'Hello';
// Fetch font from KV or R2:
const fontRes = await fetch('https://yourdomain.com/fonts/Inter-Bold.ttf');
const fontData = await fontRes.arrayBuffer();
const svg = await satori(
{ type: 'div', props: { style: { width: '100%', height: '100%', display: 'flex', background: '#111', alignItems: 'center', justifyContent: 'center' }, children: { type: 'h1', props: { style: { color: '#fff', fontSize: 48 }, children: title } } } },
{ width: 1200, height: 630, fonts: [{ name: 'Inter', data: fontData, weight: 700 }] }
);
const resvg = new Resvg(svg);
const pngData = resvg.render();
const png = pngData.asPng();
return new Response(png, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' } });
},
};Satori CSS limitations to know
- Only flexbox layout is supported — no CSS Grid.
- No
position: absoluteon nested children (only on top-level). - No CSS variables or calc().
- All text must use loaded fonts — system fonts are not available.
- Images must be data URIs or absolute HTTPS URLs (no relative paths).
- Border radius, box-shadow, linear-gradient, and most visual properties work well.
Test your OG tags free
Paste any URL into OGFixer to see exactly how your link previews look on Twitter, LinkedIn, Discord, and Slack.