concept
Server-Side Image Generation
Server-Side Image Generation
Pattern for producing PNG/JPEG/PDF output on the server, from a declarative description (JSX/HTML/template), without a browser tab. Comes up wherever you have dynamic content + fixed output dimensions + no user-side canvas: OG cards, share images, PDF receipts, photo booth print layouts, branded charts, ticket QR codes.
The decision tree
Need server-side image output
├── Layout expressible in flexbox-only JSX?
│ └── Yes → Satori + sharp ← cheapest, fastest, Edge-runtime-safe
│
├── Need full CSS, web fonts, animations resolved?
│ └── Yes → Puppeteer / Playwright ← heavyweight, slow, hi-fi
│
├── Imperative drawing (charts, primitives)?
│ └── Yes → node-canvas ← Canvas API server-side
│
└── Need PDF specifically (multi-page, vector)?
└── PDFKit, Puppeteer (HTML→PDF), or React-PDF
The Satori + sharp pipeline
Default for “JSX in, PNG out” today.
const svg = await satori(<MyDocument />, { width, height, fonts })
const png = await sharp(Buffer.from(svg)).png().toBuffer()
return new Response(png, { headers: { 'Content-Type': 'image/png' } })
satori gives you the SVG; sharp (libvips wrapper) handles SVG → PNG/JPEG/WebP, plus resize/compose if you need to overlay or watermark.
Cache aggressively — same input → same output. Cache key = stable hash of input doc + version. Layer: Cloudflare/Vercel CDN → Next.js revalidate → in-memory LRU.
When NOT to use Satori
- Web-fonts via
@font-faceURL — Satori can’t fetch them. You must load the buffer yourself and hand-list every weight. - Non-flex layouts — no grid, no
position: sticky, no transforms beyond translate/scale/rotate. - JS execution — no charts that compute layout in JS, no animations.
- Pixel-perfect parity with a real browser — Satori is “approximately a browser”, visual diffs against design comps will surface.
For those, fall back to Puppeteer/Playwright.
The Puppeteer escape hatch
const browser = await puppeteer.launch({ headless: 'new' })
const page = await browser.newPage()
await page.setContent(html)
const png = await page.screenshot({ type: 'png', clip: { ... } })
await browser.close()
Cost: ~1-3s cold start, ~200MB memory, native binary deploy headache. Use when fidelity > everything else. node-html-to-image wraps this with a friendlier API.
Font loading is the sneaky hard part
Every font weight + style + subset = a separate buffer. Build a font registry once, reuse:
// fontMap.ts — module-level, loaded once
const interRegular = await fetch(INTER_400_URL).then(r => r.arrayBuffer())
const interBold = await fetch(INTER_700_URL).then(r => r.arrayBuffer())
export const fonts = [
{ name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
{ name: 'Inter', data: interBold, weight: 700, style: 'normal' },
]
If you use Cyrillic/CJK/emoji, add the appropriate subsets — Satori falls back to Tofu (□) on missing glyphs, silently.
Caching strategy
Image generation is the right place for aggressive caching: deterministic input → deterministic output, request volume can spike (OG cards crawled by every social platform).
- CDN level:
Cache-Control: public, max-age=31536000, immutableif you version the URL (/og/v3/[slug].png) - Next.js: route handler with
export const revalidate = 3600or stronger - Application level: in-memory LRU keyed on the input hash for hot paths
- Storage level: write the PNG to S3/GCS/Firebase Storage on first generation, serve subsequent reads from there
Dimension discipline
Pick output sizes early and don’t drift. OG = 1200×630. Twitter card = 1200×600. Instagram square = 1080×1080. Print-ready Polaroid = whatever DPI × physical-size your printer wants. Build the document at the target dimension, don’t resize after — text rendering breaks at sub-pixel sizes.
Used in
- kulevents —
app/layout-image/[layoutId]/route.tsxreads a Firestore Layout doc (text + image elements with percent-based positions), renders via Satori at fixed Polaroid dimensions, sharp converts to PNG. Customer-facing — preview, download, and print pipelines all hit this endpoint. Fonts pre-loaded infontMap.ts.