How to Generate WebP Images in Node.js
WebP is 30–50% smaller than PNG with the same quality and full alpha support. Here's every way to generate WebP server-side in Node.js — and which approach fits your stack.
WebP is the format you should be serving in 2026. It delivers the same visual quality as PNG at 30–50% smaller file sizes, supports full alpha transparency, and is supported by every modern browser. If you're still generating PNGs server-side and serving them directly to users, you're sending more bytes than you need to.
This guide covers every practical way to generate WebP images in Node.js — from low-level sharp pipelines to calling a render API — and when each approach makes sense.
Why WebP over PNG?
The practical advantages in a server-side context:
- Smaller files, same quality: A 1200×630 OG image that's 180 KB as PNG is typically 85–110 KB as WebP. At scale that's meaningful bandwidth and CDN cost.
- Alpha transparency: Unlike JPEG, WebP supports transparency — so it's a direct drop-in for PNG in overlays, logos, and badges.
- Universal browser support: Chrome, Firefox, Safari (since 14), Edge, and all mobile browsers support WebP. This is no longer an edge case.
- Next.js serves it automatically: The Next.js
<Image>component converts and serves WebP for you — but only for images it controls. Dynamically generated images you produce yourself don't go through that pipeline.
Option 1: sharp (raster input only)
If you already have a PNG or JPEG buffer — from a screenshot, an upload, or another library — sharp can transcode it to WebP in a single call:
import sharp from 'sharp';
const webp = await sharp('input.png')
.webp({ quality: 85 })
.toBuffer();quality ranges from 1–100. 80–85 is the sweet spot for most use cases — indistinguishable from lossless at a fraction of the size. You can also pass { lossless: true } if you need exact pixel reproduction.
The limitation: sharp only converts existing raster images. It can't render HTML, Tailwind, or Markdown — you still need something to produce the initial image before sharp can encode it.
Option 2: Puppeteer screenshot with WebP type
Puppeteer's page.screenshot() accepts type: 'webp' directly since Chromium 88:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 630 });
await page.setContent(`
<div style="width:1200px;height:630px;background:#0a0a0a;
display:flex;align-items:center;justify-content:center;
color:white;font-size:64px;font-weight:bold;font-family:sans-serif;">
Hello world
</div>
`);
const webp = await page.screenshot({ type: 'webp', quality: 85 });
await browser.close();This gives you full CSS fidelity — every layout property, every custom font, every shadow renders correctly. The downside is the same as always with Puppeteer: a 300 MB Chromium binary, Linux system dependencies (libglib2.0, libnss3, etc.), and a cold-start time of ~1–2 seconds per invocation unless you keep the browser warm.
In a long-running Node.js server where you can reuse a browser instance across requests, Puppeteer is a reasonable choice. In a serverless function that spins up cold, it's painful.
Option 3: Satori + resvg + sharp (serverless-friendly, limited CSS)
Satori converts a JSX/HTML tree to SVG without a browser, resvg-js rasterizes the SVG to PNG, and sharp transcodes the PNG to WebP:
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import sharp from 'sharp';
import fs from 'fs';
const svg = await satori(
{
type: 'div',
props: {
style: {
background: '#0a0a0a',
color: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 64,
fontWeight: 'bold',
},
children: 'Hello world',
},
},
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: fs.readFileSync('./Inter.woff'),
weight: 400,
style: 'normal',
},
],
}
);
const png = new Resvg(svg).render().asPng();
const webp = await sharp(png).webp({ quality: 85 }).toBuffer();The appeal: no Chromium, runs in any Node.js environment including Edge Functions. The catch: Satori only implements flexbox layout. No CSS grid, no absolute positioning, no pseudo-elements. Custom fonts must be loaded as ArrayBuffer. Several Tailwind utilities silently no-op (notably gap-* and color shades above 900). For simple cards it's excellent. For anything complex you'll hit its limits.
Option 4: A render API (recommended for most teams)
If you don't want to manage Chromium binaries, debug Satori CSS gaps, or maintain a font loading pipeline, a render API handles all of it for you. You POST your Tailwind or HTML template, get back a WebP binary.
const res = await fetch('https://renderfy.io/api/v1/render', {
method: 'POST',
headers: {
'Authorization': 'Bearer rfy_live_YOUR_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'tailwind',
content: `
<div class="flex h-full w-full flex-col justify-between bg-zinc-900 p-16 text-white">
<span class="text-sm font-medium text-emerald-400">acme.com</span>
<div>
<h1 class="text-6xl font-bold leading-none">Your post title</h1>
<p class="mt-4 text-2xl text-zinc-400">A short description here</p>
</div>
<span class="text-sm text-zinc-500">Published July 2026</span>
</div>
`,
options: { format: 'webp', dimensions: 'og-image' },
}),
});
const webp = Buffer.from(await res.arrayBuffer());The format: "webp" parameter is all it takes. The API handles the rendering engine, font loading, and encoding — the response is a ready-to-serve WebP binary. Failed renders are automatically refunded (1 credit per WebP render, same as PNG).
This approach works from any runtime: Vercel Edge Functions, Cloudflare Workers, AWS Lambda, a cron job, or a plain Node.js server. No binary to install, no cold-start overhead on your side.
Serving the WebP correctly
Once you have a WebP buffer, set the right Content-Type header when serving it:
// Next.js App Router route handler
export async function GET() {
const webp = await generateWebp(); // your generation logic
return new Response(webp, {
headers: { 'Content-Type': 'image/webp' },
});
}If you're storing in S3 or another object store, set the object's ContentType to image/webp at upload time — don't rely on file extension detection, which isn't always reliable.
WebP for Open Graph images
One important caveat: some social crawlers — particularly older versions of Twitter/X and LinkedIn — don't reliably render WebP in link previews. For og:image specifically, PNG is the safest choice for maximum compatibility. For images served directly to users in <img> tags or via a CDN, WebP is the right default.
A practical strategy: generate WebP for everything served to browsers, keep PNG for og:image meta tags.
Decision guide
- Transcoding existing images: sharp — one dependency, fastest option
- Simple cards, serverless, no Chromium: Satori + resvg + sharp — lightweight but limited CSS
- Full CSS fidelity, you manage infra: Puppeteer with a warm browser instance
- Full CSS fidelity, no ops burden: Render API — one POST, WebP back
For most teams generating OG images, share cards, or document thumbnails, the render API path is the simplest to ship and maintain. You write a Tailwind template once, call the endpoint with format: "webp", and get a production-ready image back — no build step, no binary, no maintenance.
Further reading
- Converting HTML to Images in JavaScript: The Complete Guide — a broader look at all server-side image generation approaches
- HTML to Image API — full reference for PNG, JPEG, and WebP output from HTML and Tailwind templates
- Open Graph Image API — generating dynamic OG images with the right dimensions and format for every social platform