All posts
·6 min read·By Oliver Renderfy

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.

Node.jsImagesWebPTutorial

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

Try it yourself

100 free credits. No card needed. First render in under 5 minutes.

Get started free