Polotno

Bulk certificate generation in Node.js – from CSV to a folder of PDFs

Every course platform, event organizer, and HR tool eventually hits the same ticket: "we need certificates for 400 people by Friday." The data is a spreadsheet. The output is one personalized file per row. And the tooling landscape for this is strangely bad — which is why the usual paths all hurt:

  • Manual design-tool duplication. Someone edits 400 names by hand. Works exactly once, then the course runs again next month.
  • Coordinate-based PDF libraries. pdfkit or pdf-lib with hardcoded x/y positions for every text box. The first time the designer wants a new layout, an engineer is repositioning text by trial and error.
  • HTML-to-PDF via Puppeteer. Better, but now certificate layout is CSS, long names overflow their containers silently, and nobody who isn't an engineer can touch the template.

The structural fix is separating the two jobs: a template that a human designs visually, and a render loop that fills it from data. That's the pipeline this post builds — about 40 lines of Node.js.

Step 1 — design the template once

Create the certificate in any Polotno-based editor — Polotno Studio works, no account needed. Set the canvas to your print size (A4 landscape at 300 DPI is 3508 × 2480 px), lay out the border, logo, signatures, and put placeholder tokens where the data goes:

  • a text element containing {{name}}
  • one containing {{course}}
  • one containing {{date}}

Export the design as JSON (File → Save as JSON in Studio). That JSON file is your template — every element, font, and position, in an open, documented schema. Check it into the repo next to the script. When the design changes, a non-engineer edits it in the editor and re-exports; the pipeline doesn't change.

Step 2 — the render loop

Install the two dependencies:

bash
npm install polotno-node csv-parse

polotno-node runs the same rendering engine the editor uses, headless. Same JSON in, same pixels out — what the designer saw is what renders.

js
const fs = require("fs");
const { parse } = require("csv-parse/sync");
const { createInstance } = require("polotno-node");

// people.csv: name,course,date
const rows = parse(fs.readFileSync("people.csv"), { columns: true });
const template = JSON.parse(fs.readFileSync("certificate-template.json"));

// Replace {{tokens}} in every text element of a design JSON.
function fillTemplate(json, data) {
  const design = structuredClone(json);
  for (const page of design.pages) {
    for (const el of page.children) {
      if (el.type !== "text") continue;
      el.text = el.text.replace(
        /\{\{(\w+)\}\}/g,
        (match, key) => data[key] ?? match,
      );
    }
  }
  return design;
}

async function run() {
  const instance = await createInstance({
    key: "YOUR_API_KEY", // https://polotno.com/cabinet
    useParallelPages: false, // sequential loop → one page is faster
  });

  fs.mkdirSync("certificates", { recursive: true });

  for (const row of rows) {
    const design = fillTemplate(template, row);
    const pdfBase64 = await instance.jsonToPDFBase64(design);
    const fileName = row.name.replace(/[^a-z0-9]/gi, "-").toLowerCase();
    fs.writeFileSync(`certificates/${fileName}.pdf`, pdfBase64, "base64");
    console.log(`✓ ${row.name}`);
  }

  instance.close();
}

run();

That's the whole pipeline. node generate.js turns a CSV and a template into a folder of PDFs.

Want PNGs for email embedding instead? Swap the export call:

js
const imageBase64 = await instance.jsonToImageBase64(design, {
  mimeType: "image/png",
  pixelRatio: 1, // 2 for retina-quality email attachments
});
fs.writeFileSync(`certificates/${fileName}.png`, imageBase64, "base64");

The details that bite in production

Six years of watching teams run this exact workflow; these are the failures that come back:

Long names. "Ana Li" and "Aleksandra Konstantinopolskaya-Fernandez" both have to fit. In the template, give the name element a fixed width and center alignment — Polotno wraps text within the element's box, so the layout holds instead of overflowing off the certificate. Test your template with the longest name in the sheet, not the first one.

Fonts. Custom fonts referenced in the design are loaded at render time; Google Fonts are cached across renders by default (useFontCache). If certificates come out in a fallback font, the font URL isn't reachable from the server — fix the source, don't screenshot around it.

Missing columns. The ?? match fallback above leaves unmatched {{tokens}} visible in the output. That's deliberate: a certificate that ships with {{course}} on it gets caught in review; a silently blank one gets mailed.

Instance reuse. createInstance boots a headless browser — do it once, not per row. Sequential rendering with a reused instance comfortably handles hundreds of certificates; a 400-row batch is a coffee break, not an infrastructure project.

Validation pass. Before mailing anything, render row 1, eyeball it, then run the batch. Automated pipelines fail at the data layer far more often than the render layer — a BOM in the CSV header, a date column in three formats, a name with a tab in it.

Scaling past the script

The script above is the right size for a recurring batch job. Two directions when requirements grow:

Volume. If certificates render inside a request/response cycle, or batches reach tens of thousands, move the render call to the Cloud Render API — same JSON, POST instead of local Puppeteer, no render infrastructure to babysit. The batch rendering at scale post covers queueing patterns.

Letting users design the template. The step after "our team's template" is "our customers' templates" — an LMS where instructors design their own certificates, an event platform where organizers do. That's the embedded editor use case: the same JSON schema your render loop already consumes, produced by an editor inside your product. Template governance — locked layouts with editable text slots — is the feature that keeps user-designed certificates printable; see dynamic template variables for the structured version of the token approach.

The 60-day free trial covers the whole evaluation, CSV to inbox.

Skip the build, cut dev costs, launch faster

TRUSTED BY

100,000+

CREATORS

300+

BUSINESSES

ExpediaUnbounceLovePopPostGridPredis.ai