node-canvas vs @napi-rs/canvas vs skia-canvas: Server-Side Canvas in Node.js (2026)
TL;DR
node-canvas is the original server-side canvas — uses Cairo graphics library, longest track record, most compatible with the browser Canvas API. @napi-rs/canvas is the modern choice — uses Skia (same as Chrome), ships pre-built binaries via N-API so no compile-time native build needed, faster than node-canvas. skia-canvas is another Skia-based option — excellent SVG and text rendering, multi-threading support. For OG image generation in Next.js: use @vercel/og (based on Satori + resvg). For general server-side canvas: @napi-rs/canvas is the best balance of performance and ease of setup.
Key Takeaways
- node-canvas: ~800K weekly downloads — Cairo-based, requires system libs (libcairo, etc.), well-tested
- @napi-rs/canvas: ~500K weekly downloads — Skia-based, pre-built binaries, no system deps, faster
- skia-canvas: ~50K weekly downloads — Skia-based, multi-threaded, excellent SVG support
- All three implement the browser Canvas 2D API — code is largely portable
- node-canvas requires
brew install cairo(macOS) orapt-get install libcairo2-dev(Linux) - @napi-rs/canvas ships pre-built binaries — works on install without native build tools
- For Next.js OG images specifically: use
@vercel/ogorsatoriinstead
Server-Side Canvas Use Cases
Common uses:
- OG image generation (dynamic social media previews)
- Thumbnail/preview generation
- Chart/graph rendering to PNG
- Certificate or badge generation
- Watermarking images
- Dynamic banner creation for social platforms
- Server-side chart rendering (Chart.js, D3)
node-canvas
node-canvas — the original, Cairo-based server canvas:
Setup
# macOS:
brew install pkg-config cairo pango libpng jpeg giflib librsvg pixman
# Ubuntu/Debian:
sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
# Then install:
npm install canvas
Basic usage
import { createCanvas, loadImage, registerFont } from "canvas"
import fs from "fs"
// Create canvas (same API as browser):
const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")
// Background:
ctx.fillStyle = "#1a1a2e"
ctx.fillRect(0, 0, 1200, 630)
// Text:
ctx.fillStyle = "#ffffff"
ctx.font = "bold 72px sans-serif"
ctx.fillText("PkgPulse Report", 80, 200)
ctx.fillStyle = "#FF8800"
ctx.font = "36px sans-serif"
ctx.fillText("react vs vue — 2026 Data", 80, 280)
// Save as PNG:
const buffer = canvas.toBuffer("image/png")
fs.writeFileSync("og-image.png", buffer)
// Or get data URL:
const dataUrl = canvas.toDataURL("image/png")
Custom fonts
import { createCanvas, registerFont } from "canvas"
import path from "path"
// Register fonts before use:
registerFont(path.join(process.cwd(), "fonts/Inter-Bold.ttf"), {
family: "Inter",
weight: "bold",
})
registerFont(path.join(process.cwd(), "fonts/Inter-Regular.ttf"), {
family: "Inter",
weight: "normal",
})
const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")
ctx.font = "bold 72px Inter" // Now uses the registered font
ctx.fillText("Custom Font Text", 80, 200)
Draw with images
import { createCanvas, loadImage } from "canvas"
async function generateCard(packageName: string, score: number) {
const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")
// Gradient background:
const gradient = ctx.createLinearGradient(0, 0, 1200, 630)
gradient.addColorStop(0, "#1e1e2e")
gradient.addColorStop(1, "#2d2d44")
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 1200, 630)
// Load and draw logo:
const logo = await loadImage("public/logo.png")
ctx.drawImage(logo, 80, 80, 120, 120)
// Score circle:
ctx.beginPath()
ctx.arc(1060, 315, 120, 0, 2 * Math.PI)
ctx.fillStyle = score >= 80 ? "#22C55E" : score >= 60 ? "#EAB308" : "#EF4444"
ctx.fill()
ctx.fillStyle = "#ffffff"
ctx.font = "bold 72px sans-serif"
ctx.textAlign = "center"
ctx.fillText(String(score), 1060, 340)
ctx.font = "24px sans-serif"
ctx.fillText("score", 1060, 380)
return canvas.toBuffer("image/png")
}
@napi-rs/canvas
@napi-rs/canvas — Skia-based, pre-built binaries:
Setup (no native build needed)
# No system dependencies needed — pre-built binaries included:
npm install @napi-rs/canvas
# Works immediately on macOS (Intel + Apple Silicon), Linux (x64, arm64), Windows
Basic usage (same Canvas 2D API)
import { createCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas"
import fs from "fs"
// Identical to node-canvas API:
const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")
// Gradient background:
const gradient = ctx.createLinearGradient(0, 0, 1200, 0)
gradient.addColorStop(0, "#FF8800")
gradient.addColorStop(1, "#FF4400")
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 1200, 630)
// Text with anti-aliasing (Skia renders text beautifully):
ctx.fillStyle = "#ffffff"
ctx.font = "bold 64px sans-serif"
ctx.fillText("react vs vue", 80, 200)
// Save:
const pngBuffer = await canvas.encode("png") // Note: async encode
fs.writeFileSync("output.png", pngBuffer)
// JPEG with quality control:
const jpegBuffer = await canvas.encode("jpeg", 90) // 90% quality
Font handling
import { createCanvas, GlobalFonts } from "@napi-rs/canvas"
import { readFileSync } from "fs"
import { join } from "path"
// Register fonts globally:
GlobalFonts.registerFromPath(join(process.cwd(), "fonts/Inter-Bold.ttf"), "Inter")
GlobalFonts.registerFromPath(join(process.cwd(), "fonts/Inter-Regular.ttf"), "Inter")
// Or register from buffer:
const fontData = readFileSync("fonts/GeistMono-Regular.ttf")
GlobalFonts.register(fontData, "GeistMono")
// Use in canvas:
const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")
ctx.font = "bold 64px Inter"
ctx.fillText("Using Custom Fonts", 80, 200)
OG image in Next.js API route
// app/api/og/route.ts
// Note: For Next.js, @vercel/og is usually better — this is for custom needs
export const runtime = "nodejs" // edge runtime doesn't support node-canvas or @napi-rs/canvas
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const title = searchParams.get("title") ?? "PkgPulse"
const pkg = searchParams.get("pkg") ?? "react"
const { createCanvas, GlobalFonts } = await import("@napi-rs/canvas")
const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")
// Draw...
ctx.fillStyle = "#0f172a"
ctx.fillRect(0, 0, 1200, 630)
ctx.fillStyle = "#ffffff"
ctx.font = "bold 64px sans-serif"
ctx.fillText(title, 60, 240)
const buffer = await canvas.encode("png")
return new Response(buffer, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600",
},
})
}
skia-canvas
skia-canvas — multi-threaded Skia canvas:
Distinctive features
import { Canvas, loadImage, FontLibrary } from "skia-canvas"
// Multi-threaded rendering — process a batch of images in parallel:
async function generateBatchOGImages(packages: string[]) {
const canvases = packages.map((pkg) => {
const canvas = new Canvas(1200, 630)
const ctx = canvas.getContext("2d")
ctx.fillStyle = "#1a1a2e"
ctx.fillRect(0, 0, 1200, 630)
ctx.fillStyle = "#ffffff"
ctx.font = "bold 64px sans-serif"
ctx.fillText(pkg, 80, 315)
return canvas
})
// Render all in parallel (uses thread pool):
return Promise.all(canvases.map((c) => c.toBuffer("png")))
}
SVG export
import { Canvas } from "skia-canvas"
// skia-canvas can export to SVG directly:
const canvas = new Canvas(800, 400)
const ctx = canvas.getContext("2d")
ctx.fillStyle = "#3B82F6"
ctx.beginPath()
ctx.arc(400, 200, 150, 0, 2 * Math.PI)
ctx.fill()
ctx.fillStyle = "#ffffff"
ctx.font = "bold 48px sans-serif"
ctx.textAlign = "center"
ctx.fillText("React", 400, 215)
// Export as SVG string:
const svg = await canvas.toBuffer("svg")
// Returns SVG markup as Buffer — can be served directly
Feature Comparison
| Feature | node-canvas | @napi-rs/canvas | skia-canvas |
|---|---|---|---|
| Rendering engine | Cairo | Skia (Chrome) | Skia |
| Pre-built binaries | ❌ | ✅ | ✅ |
| System deps | ✅ Required | ❌ | ❌ |
| PNG output | ✅ | ✅ | ✅ |
| JPEG output | ✅ | ✅ | ✅ |
| SVG output | ✅ | ❌ | ✅ |
| Custom fonts | ✅ | ✅ | ✅ |
| Multi-threading | ❌ | ❌ | ✅ |
| Performance | Moderate | Fast | Fast |
| Browser API compat | ✅ High | ✅ High | ✅ High |
| TypeScript | ✅ | ✅ | ✅ |
| Emoji rendering | ⚠️ | ✅ | ✅ |
When to Use Each
Choose @napi-rs/canvas if:
- You want the best balance of performance and zero system dependency setup
- Docker/serverless deployment (no
apt-getneeded) - Skia's text rendering quality is important
- Most use cases — this is the recommended default in 2026
Choose node-canvas if:
- You need maximum Cairo API compatibility
- Existing codebase already uses it and migration isn't worth it
- You need specific Cairo features not in Skia
Choose skia-canvas if:
- Processing large batches of images (multi-threading benefit)
- You specifically need SVG export alongside PNG
- Maximum text rendering quality
Consider @vercel/og instead for Next.js OG images:
// @vercel/og uses Satori (HTML/CSS → SVG → PNG) — no canvas needed
// Works on Vercel Edge runtime — much simpler for OG images:
// app/opengraph-image.tsx
import { ImageResponse } from "next/og"
export default function OGImage() {
return new ImageResponse(
<div style={{ background: "#1a1a2e", width: "1200px", height: "630px" }}>
<h1 style={{ color: "white", fontSize: "72px" }}>PkgPulse</h1>
</div>,
{ width: 1200, height: 630 }
)
}
// JSX → PNG — no canvas API knowledge required
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on node-canvas v2.x, @napi-rs/canvas v0.7.x, and skia-canvas v1.x.
Compare image processing and rendering packages on PkgPulse →