Skip to main content

ipx vs @vercel/og vs satori: Dynamic Image Generation in Node.js (2026)

·PkgPulse Team

TL;DR

ipx is the UnJS image optimization server — on-demand image resizing, format conversion, and optimization via URL parameters, powers Nuxt Image. @vercel/og is Vercel's OG image generator — generates Open Graph images from JSX/HTML at the edge using satori under the hood, designed for Next.js. satori is the HTML/CSS-to-SVG converter — converts JSX to SVG using a layout engine, works anywhere (not tied to Vercel), used as the core of @vercel/og. In 2026: ipx for image optimization/CDN, @vercel/og for Next.js OG images, satori for custom HTML-to-image conversion.

Key Takeaways

  • ipx: ~1M weekly downloads — UnJS, image optimization server, URL-based transforms
  • @vercel/og: ~500K weekly downloads — Vercel, OG image generation, JSX → PNG
  • satori: ~1M weekly downloads — Vercel, JSX/HTML → SVG, layout engine, works anywhere
  • Different tools: ipx optimizes existing images, satori/og generate new images from HTML
  • satori is the engine inside @vercel/og — use satori directly for non-Vercel projects
  • ipx is like a self-hosted Cloudinary/imgix

ipx

ipx — image optimization server:

Basic server

import { createIPX, createIPXH3Handler } from "ipx"
import { createApp, toNodeListener } from "h3"
import { createServer } from "node:http"

const ipx = createIPX({
  dir: "./public/images",  // Source directory
})

const app = createApp()
app.use("/_ipx", createIPXH3Handler(ipx))

createServer(toNodeListener(app)).listen(3000)

// Now access images with transforms via URL:
// http://localhost:3000/_ipx/w_800,h_600,f_webp/photo.jpg
// → Resizes to 800x600 and converts to WebP

URL-based transforms

Transform syntax: /_ipx/{transforms}/{path}

Resize:
  /_ipx/w_800/photo.jpg           → Width 800px (auto height)
  /_ipx/h_600/photo.jpg           → Height 600px (auto width)
  /_ipx/w_800,h_600/photo.jpg     → Exact 800x600
  /_ipx/s_200x200/photo.jpg       → Square 200x200

Format conversion:
  /_ipx/f_webp/photo.jpg          → Convert to WebP
  /_ipx/f_avif/photo.png          → Convert to AVIF
  /_ipx/f_png/photo.jpg           → Convert to PNG

Quality:
  /_ipx/q_80/photo.jpg            → 80% quality
  /_ipx/w_800,q_75,f_webp/photo.jpg → Resize + quality + format

Fit modes:
  /_ipx/fit_cover,w_400,h_400/photo.jpg    → Cover (crop)
  /_ipx/fit_contain,w_400,h_400/photo.jpg  → Contain (letterbox)
  /_ipx/fit_fill,w_400,h_400/photo.jpg     → Fill (stretch)

Remote images

import { createIPX } from "ipx"

const ipx = createIPX({
  // Allow remote image sources:
  domains: ["images.unsplash.com", "avatars.githubusercontent.com"],

  // Or allow any remote:
  fetchOptions: {
    headers: { "User-Agent": "IPX/1.0" },
  },
})

// /_ipx/w_400,f_webp/https://images.unsplash.com/photo-123

How Nuxt Image uses ipx

// In Nuxt, <NuxtImg> uses ipx for optimization:

// <NuxtImg src="/photo.jpg" width="800" format="webp" />
// Generates: /_ipx/w_800,f_webp/photo.jpg

// nuxt.config.ts:
export default defineNuxtConfig({
  image: {
    provider: "ipx",  // Default — uses built-in ipx
    // Or use external providers:
    // provider: "cloudinary",
    // provider: "imgix",
  },
})

@vercel/og

@vercel/og — OG image generation:

Next.js App Router

// app/api/og/route.tsx
import { ImageResponse } from "@vercel/og"

export const runtime = "edge"

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const title = searchParams.get("title") ?? "PkgPulse"

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          backgroundColor: "#0a0a0a",
          color: "white",
          fontSize: 60,
          fontFamily: "Inter",
        }}
      >
        {title}
      </div>
    ),
    {
      width: 1200,
      height: 630,
    }
  )
}

// Usage: /api/og?title=React%20vs%20Vue
// Returns a 1200x630 PNG image

Dynamic OG images

// app/api/og/route.tsx
import { ImageResponse } from "@vercel/og"

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const pkg1 = searchParams.get("pkg1") ?? "react"
  const pkg2 = searchParams.get("pkg2") ?? "vue"

  return new ImageResponse(
    (
      <div style={{
        width: "100%",
        height: "100%",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
        color: "white",
        padding: 60,
      }}>
        <div style={{ fontSize: 32, opacity: 0.8, marginBottom: 20 }}>
          PkgPulse Comparison
        </div>
        <div style={{ fontSize: 72, fontWeight: 700 }}>
          {pkg1} vs {pkg2}
        </div>
        <div style={{ fontSize: 28, opacity: 0.7, marginTop: 20 }}>
          Compare npm packages side by side
        </div>
      </div>
    ),
    { width: 1200, height: 630 },
  )
}

Custom fonts

import { ImageResponse } from "@vercel/og"

// Load custom font:
const interBold = fetch(
  new URL("../../assets/Inter-Bold.ttf", import.meta.url)
).then((res) => res.arrayBuffer())

export async function GET() {
  return new ImageResponse(
    <div style={{ fontFamily: "Inter", fontWeight: 700, fontSize: 72 }}>
      PkgPulse
    </div>,
    {
      width: 1200,
      height: 630,
      fonts: [
        { name: "Inter", data: await interBold, weight: 700 },
      ],
    },
  )
}

satori

satori — HTML/CSS to SVG:

Basic usage

import satori from "satori"

// Convert JSX to SVG:
const svg = await satori(
  <div style={{
    width: "100%",
    height: "100%",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    backgroundColor: "#1a1a1a",
    color: "white",
    fontSize: 48,
  }}>
    Hello, World!
  </div>,
  {
    width: 1200,
    height: 630,
    fonts: [{
      name: "Inter",
      data: fontBuffer,  // ArrayBuffer of font file
      weight: 400,
    }],
  },
)

// svg is a string of SVG markup
console.log(svg)
// → "<svg width="1200" height="630">...</svg>"

Convert SVG to PNG

import satori from "satori"
import sharp from "sharp"

// Generate SVG:
const svg = await satori(
  <div style={{ /* ... */ }}>PkgPulse</div>,
  { width: 1200, height: 630, fonts: [/* ... */] },
)

// Convert to PNG with sharp:
const png = await sharp(Buffer.from(svg))
  .png()
  .toBuffer()

// Or use @resvg/resvg-js for edge environments:
import { Resvg } from "@resvg/resvg-js"
const resvg = new Resvg(svg)
const pngData = resvg.render()
const pngBuffer = pngData.asPng()

Complex layouts

import satori from "satori"

const svg = await satori(
  <div style={{
    display: "flex",
    flexDirection: "column",
    width: "100%",
    height: "100%",
    padding: 60,
    backgroundColor: "#0f172a",
  }}>
    {/* Header */}
    <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
      <img src="https://pkgpulse.com/logo.png" width={48} height={48} />
      <span style={{ color: "#94a3b8", fontSize: 24 }}>PkgPulse</span>
    </div>

    {/* Main content */}
    <div style={{
      flex: 1,
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
    }}>
      <h1 style={{ color: "white", fontSize: 64 }}>
        React vs Vue
      </h1>
    </div>

    {/* Footer */}
    <div style={{ color: "#64748b", fontSize: 20 }}>
      Compare npm packages at pkgpulse.com
    </div>
  </div>,
  { width: 1200, height: 630, fonts: [/* ... */] },
)

Supported CSS

satori supports a subset of CSS (flexbox-based):

Layout:
  ✅ display: flex
  ✅ flexDirection, alignItems, justifyContent
  ✅ gap, padding, margin
  ✅ width, height, maxWidth, maxHeight
  ✅ position: absolute/relative
  ✅ overflow: hidden

Typography:
  ✅ fontSize, fontWeight, fontFamily
  ✅ color, textAlign, lineHeight
  ✅ letterSpacing, textDecoration
  ✅ textOverflow: ellipsis

Visuals:
  ✅ backgroundColor, backgroundImage (linear-gradient)
  ✅ borderRadius, border, borderColor
  ✅ boxShadow, opacity
  ✅ <img> tags with src

NOT supported:
  ❌ display: grid
  ❌ CSS animations/transitions
  ❌ :hover, :focus pseudo-classes
  ❌ media queries

Feature Comparison

Featureipx@vercel/ogsatori
PurposeImage optimizationOG image generationHTML → SVG
InputExisting imagesJSX/HTMLJSX/HTML
OutputOptimized imagePNG imageSVG string
Resize/crop
Format conversion✅ (WebP, AVIF)PNG onlySVG
URL-based API
Edge runtime❌ (needs sharp)
Custom fontsN/A
Flexbox layoutN/A
FrameworkAny (H3)Next.jsAny
Used byNuxt ImageNext.js@vercel/og
Weekly downloads~1M~500K~1M

When to Use Each

Use ipx if:

  • Need on-demand image optimization (resize, format, quality)
  • Building a self-hosted image CDN
  • Using Nuxt Image or need URL-based transforms
  • Want to optimize existing images without a third-party service

Use @vercel/og if:

  • Generating OG images in Next.js
  • Want the simplest API for dynamic social images
  • Deploying on Vercel (edge runtime)
  • Need PNG output from JSX templates

Use satori if:

  • Need HTML/CSS → SVG conversion outside Next.js/Vercel
  • Building custom image generation (badges, certificates, cards)
  • Want to use sharp or resvg for final PNG conversion
  • Need the most flexibility in image generation

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on ipx v3.x, @vercel/og v0.6.x, and satori v0.10.x.

Compare image generation and media tooling on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.