concept

Server-Side Image Generation

created 2026-05-10 image-generation · satori · sharp · puppeteer · og-images · pdf · print

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-face URL — 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, immutable if you version the URL (/og/v3/[slug].png)
  • Next.js: route handler with export const revalidate = 3600 or 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

  • kuleventsapp/layout-image/[layoutId]/route.tsx reads 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 in fontMap.ts.

See also

  • satori — primary tool
  • kulevents — production usage with photo booth layouts