Server-side Chart.js to PNG looks simple until you actually try it. The canonical path runs through node-canvas, which needs Cairo, Pango, libpng, jpeg, giflib, and librsvg — native dependencies that break on macOS and exceed serverless function size limits [1][2]. Chart.js issue #8831 proposed node-canvas as the fix, but that path still leaves you fighting install errors, blurry output, and plugins that don't render [3][4][5][6]. The easier route: build the chart as a reusable HTML component, send it to a browser engine via API, and get back a pixel-perfect PNG buffer.
Chart.js is built on the HTML5 canvas element, so using it in Node.js requires mimicking that element [4]. The standard wrapper, chartjs-node-canvas, inherits node-canvas's native dependency stack [7]. Users hit "Error: Cannot find module 'canvas'" repeatedly, and the package's own readme states it is limited by the upstream dependency canvas [8]. Installing those native libraries on macOS requires Homebrew packages like cairo and pango [2].
chartjs-node-canvas also disables Chart.js animation and responsive resize because Node lacks the browser APIs that drive them [9]. Gradients render differently than in a real browser, and some plugins don't work at all [6]. A Stack Overflow user reported that images generated by the plugin were too blurred [4], and a Reddit user hit "'candlestick' is not a registered controller" when using chartjs-node-canvas with financial chart types [5].
On Vercel, a function using chartjs-node-canvas exceeded the 50MB limit at 50.55MB, with the canvas dependency alone taking 42.7MB [1]. AWS Lambda requires workarounds like building on an Amazon Linux 2 EC2 instance and checking compiled node_modules into git, which defeats the purpose of serverless [10][11].
Install the client and initialize it with your API key [12]:
npm install markupgo-node
import MarkupGo from "markupgo-node";
const markupgo = new MarkupGo({
API_KEY: process.env.MARKUPGO_API_KEY,
});
This client is the only runtime dependency your chart service needs. No native libraries, no compiled node_modules, no version pinning against a specific Lambda runtime.
The template is your reusable component — HTML, CSS, and JavaScript defined once and rendered many times with different data [13]. It needs an HTML canvas element, Chart.js loaded from a CDN in the libraries field, and your chart configuration passed via context. Here's the structure:
const templateData = {
id: "YOUR_TEMPLATE_ID", // create this in the MarkupGo dashboard
context: {
labels: ["Jan", "Feb", "Mar", "Apr", "May"],
values: [12, 19, 8, 15, 22],
title: "Monthly Active Users"
},
autoHeight: true
};
<div style="width: 800px; padding: 40px; background: white;">
<canvas id="chart"></canvas>
</div>
<script>
const ctx = document.getElementById('chart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: {{ labels }},
datasets: [{
label: {{ title }},
data: {{ values }},
backgroundColor: 'rgba(99, 102, 241, 0.6)'
}]
},
options: {
responsive: false,
animation: false,
plugins: {
legend: { display: true }
}
}
});
</script>
The libraries field in your template includes https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js [13]. Set autoHeight: true so the output size fits your content rather than clipping to a fixed height [13].
Treat the context object as the component's props. Swap labels, values, and title per render and the same template ID serves every chart in your product — dashboards, invoices, email digests — without duplicating markup.
Call image.fromTemplate with your template ID, context, and format set to png [12].
import * as fs from "fs";
// Get back a buffer and write to file
const buffer = await markupgo.image.fromTemplate(templateData, {
properties: {
format: "png",
width: 880,
height: 600
}
}).buffer();
fs.writeFileSync("chart.png", Buffer.from(buffer));
Supported output formats are PNG, JPEG, and WebP [12]. The same template component drives PDF generation too — swap image.fromTemplate for pdf.fromTemplate to embed the chart in a full report [12]:
const pdfBuffer = await markupgo.pdf.fromTemplate(templateData, {
properties: {
printBackground: true,
landscape: true
}
}).buffer();
fs.writeFileSync("report.pdf", Buffer.from(pdfBuffer));
One template, two output pipelines. That's the payoff of building the chart as a component instead of a one-off script: the PNG for the dashboard and the PDF report pull from the same source of truth, so a styling change happens in one place.
For teammates who shouldn't touch Node at all, enable the Magic Template URL on your template and generate images from query parameters without writing any code [14]. The URL structure is https://render.markupgo.com/template/{templateId}.png?labels=Jan,Feb,Mar&values=12,19,8&title=MAU. Responses cache for 15 days, so reusing the same URL doesn't consume additional credits [14].
This turns your chart component into something anyone can consume — paste the URL into a Notion doc, a README, or an <img> tag, and the query string acts as the props.
MarkupGo runs a perpetual free trial of 100 credits per month with no credit card required [15]. Paid plans start at Lite for $29 per month with 2,000 credits and 5 templates, Plus at $49 per month with 10,000 credits and 30 templates, and Pro at $99 per month with 20,000 credits and unlimited templates [15].
Sign up for the free tier, create a template with Chart.js in the libraries field, and call image.fromTemplate from your Node service. You'll skip the native dependency headaches entirely and get browser-accurate renders every time.
DOCX to PDF in Production: Why Headless LibreOffice Hangs, and When to Use an API
The command libreoffice --headless --convert-to pdf is the first result for "docx to pdf linux" and it is a production trap. A Stack Overflow thread…
Auto-Generate Branded PDF Invoices from Stripe Payment Events
Stripe issues a PDF for every invoice, but the layout is locked. You can upload a logo and pick a brand color, yet you cannot add VAT IDs, custom fonts, or a…