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
| Feature | ipx | @vercel/og | satori |
|---|---|---|---|
| Purpose | Image optimization | OG image generation | HTML → SVG |
| Input | Existing images | JSX/HTML | JSX/HTML |
| Output | Optimized image | PNG image | SVG string |
| Resize/crop | ✅ | ❌ | ❌ |
| Format conversion | ✅ (WebP, AVIF) | PNG only | SVG |
| URL-based API | ✅ | ❌ | ❌ |
| Edge runtime | ❌ (needs sharp) | ✅ | ✅ |
| Custom fonts | N/A | ✅ | ✅ |
| Flexbox layout | N/A | ✅ | ✅ |
| Framework | Any (H3) | Next.js | Any |
| Used by | Nuxt Image | Next.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
Migration Guide
From Cloudinary/imgix to ipx for self-hosted optimization
IPX provides a URL-based image transform API similar to cloud image CDNs, but self-hosted:
// Cloudinary URL pattern (cloud service)
// https://res.cloudinary.com/demo/image/upload/w_400,h_300,c_fill/sample.jpg
// ipx equivalent (self-hosted)
// https://yoursite.com/_ipx/w_400&h_300&fit=cover/sample.jpg
// ipx server setup (Node.js/H3)
import { createIPX, ipxFSStorage, createIPXH3Handler } from "ipx"
const ipx = createIPX({ storage: ipxFSStorage({ dir: "./public" }) })
// Mount at /_ipx/* route in your H3/Nitro/Nuxt app
Using satori outside of Next.js (Hono, Cloudflare Workers)
import satori from "satori"
import { Resvg } from "@resvg/resvg-js"
import { Hono } from "hono"
const app = new Hono()
app.get("/og/:title", async (c) => {
const title = c.req.param("title")
const fontBuffer = await fetch("https://yoursite.com/Inter.ttf").then(r => r.arrayBuffer())
const svg = await satori(
<div style={{ display: "flex", width: "100%", height: "100%", background: "#1a1a1a", color: "white", fontSize: 48, alignItems: "center", justifyContent: "center" }}>
{title}
</div>,
{ width: 1200, height: 630, fonts: [{ name: "Inter", data: fontBuffer, weight: 400 }] }
)
const png = new Resvg(svg).render().asPng()
return c.body(png, 200, { "Content-Type": "image/png" })
})
Satori CSS Limitations and Layout Workarounds
Satori implements a subset of CSS centered on flexbox, which means common CSS patterns from web development require workarounds when applied to image generation. Understanding these constraints upfront prevents spending time debugging layout behavior that looks correct in a browser but fails in Satori.
Grid layout (display: grid) is not supported — all layout must use flexbox. This is less limiting than it sounds because every grid pattern achievable in CSS Grid can be replicated with nested flex containers, but developers accustomed to grid-based design systems will need to mentally translate their layouts. A two-column layout becomes a row-direction flex container with two 50% flex children. A three-column card grid becomes a flex container with flexWrap: "wrap" and cards with width: "33.33%".
position: fixed and viewport-relative units (vh, vw, dvh) are not supported since Satori renders to a fixed-size canvas, not a viewport. Absolute positioning within a position: relative parent works correctly. CSS variables are not processed — you must inline the computed values. clip-path and mask properties are unsupported, which eliminates some common decorative techniques. Background images work only through backgroundImage: "url(...)" pointing to publicly accessible image URLs or data URIs — local file paths are not resolved.
Text rendering in Satori requires explicit font registration for every weight and style variant. System fonts are not available since the rendering happens in a server environment. If your design uses Inter at weights 400 and 700, you must load and register both variants separately. A common production issue is missing bold text — Satori will attempt to simulate bold by applying an SVG font-weight attribute, but this only works if the browser or renderer supports it. The reliable approach is always to register the explicit bold font file rather than relying on font-weight synthesis.
Community Adoption in 2026
satori sits at approximately 1 million weekly downloads, but its actual usage is much higher because @vercel/og uses satori internally for its JSX-to-SVG conversion. Every Next.js application generating OG images with @vercel/og is running satori under the hood. Direct satori usage is for developers building on non-Next.js frameworks (Hono, Astro, SvelteKit) who need the same HTML-to-image capability.
@vercel/og reaches approximately 500,000 weekly downloads, used almost exclusively in Next.js App Router projects generating social preview images. Its integration with Next.js's image optimization pipeline and edge runtime support makes it the zero-configuration choice for the most common use case. For anything outside Next.js/Vercel, the direct satori API is more appropriate.
ipx at approximately 1 million weekly downloads serves the image optimization use case rather than image generation. Nuxt Image uses ipx for its on-demand optimization pipeline. Self-hosted teams who want Cloudinary-style URL transforms without a monthly CDN bill use ipx behind a reverse proxy. The two use cases (optimization vs generation) rarely overlap — ipx handles existing images, satori/vercel-og generates new ones from code.
Image CDN, Edge Caching, and Performance Strategy
Dynamic image generation introduces latency that must be managed through caching and edge distribution to maintain acceptable response times.
IPX caching behavior is configurable through its storage option. By default, IPX caches transformed images in-memory, which is lost on process restart. For production deployments, IPX supports disk-based caching (configured via storage: 'fs') or Redis-based caching for distributed deployments. The cache key includes the image URL, width, height, format, and quality parameters, ensuring transformed variants are cached independently. Nuxt Image (which uses IPX) configures IPX's cache duration through the image.provider.options.maxAge setting, defaulting to one hour.
Vercel's image optimization edge network runs IPX-compatible image transformation on Vercel's global edge network. When you use Next.js's <Image> component or Nuxt Image with the vercel provider, transformed images are cached at Vercel's edge nodes closest to the requesting user. The first request for a new variant triggers transformation on-demand; subsequent requests for the same variant are served from edge cache with sub-10ms latency. This is the primary reason to choose the Vercel provider over self-hosted IPX — global edge distribution without managing CDN configuration.
Satori and Vercel OG for social cards require a specific caching strategy because social card images are typically generated once per content item and rarely change. The recommended pattern is to generate the image on first request and set a long Cache-Control header (max-age=31536000, s-maxage=31536000) with a content-hash in the URL. This produces CDN caches that are effectively permanent (since the URL changes when content changes). Vercel's ImageResponse helper sets appropriate cache headers automatically when used in Vercel's runtime.
Font loading for dynamic images (critical for Satori) should use preloaded fonts rather than network fetches. Loading a font file from Google Fonts on every image generation request adds 100-300ms of network latency. In Next.js, store the font Buffer in a module-level variable that is initialized once on cold start and reused across requests. Similarly, static assets used in generated images (logos, background images) should be loaded once and cached in module scope rather than fetched on every invocation.
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 →
See also: image-size vs probe-image-size vs sharp metadata and Miniflare vs Wrangler vs Workers SDK, acorn vs @babel/parser vs espree.