Skip to main content

node-canvas vs @napi-rs/canvas vs skia-canvas: Server-Side Canvas in Node.js (2026)

·PkgPulse Team

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) or apt-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/og or satori instead

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

Featurenode-canvas@napi-rs/canvasskia-canvas
Rendering engineCairoSkia (Chrome)Skia
Pre-built binaries
System deps✅ Required
PNG output
JPEG output
SVG output
Custom fonts
Multi-threading
PerformanceModerateFastFast
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-get needed)
  • 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 →

Comments

Stay Updated

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