I run the backend for a consumer promotion platform — think "scan the code inside the pack, win a prize." It's been live for over two years across three countries, and as of writing has issued 1,498,385 unique codes and processed 107,218 registrations from entrants in 233 countries.
Every single one of those codes has to be unique, forever, across every print run, in every market — because a code that's already been issued, printed on packaging, and sent to a warehouse cannot be reprinted, reassigned, or fixed after the fact. Get a duplicate into a print run and it's not a bug ticket, it's a support flood of two customers fighting over one prize.
I built two small, separate scripts to solve this, and I'm open-sourcing both.
The pipeline
Code Generator (React) QR + PNG Generator (React)
───────────────────── ──────────────────────────
length, quantity, charset → reads codes.js → QR + label
↓ ↓
download codes.csv download code.png (per code)
Deliberately two tools, not one. The person generating codes for print isn't always the person rendering QR images — codes get reviewed, deduplicated against history, and approved before anything becomes a printable asset. Keeping them separate means either step can be rerun independently.
Tool 1: the code generator
A small form: how long each code should be, how many you need, and which characters are allowed.
Nothing exotic — pick a length, pick a quantity, pick a charset (I exclude visually ambiguous characters like 0/O and 1/I by default, since these end up printed small on packaging and get misread), hit generate, download a CSV.
The interesting part isn't the generator — it's what happens after. Uniqueness isn't just "no duplicates in this batch." It has to hold against every code ever issued, across every campaign, in every market, going back over two years. That check happens at two layers:
- Application layer, when a new batch is generated: cross-check the freshly generated codes against the full historical set before the CSV is even offered for download.
- Database layer, as the real backstop: the code field carries a unique index in MongoDB, so even if two people generated overlapping batches at the same time, the database itself physically cannot accept a duplicate on registration. Enforcing this at the database rather than trusting application logic is what actually makes the "zero duplicates in 1.5 million codes" claim hold — application logic can have a bug; a unique index cannot silently fail.
Tool 2: turning a CSV into printable QR codes
This is the part I get asked about the most, so here's the real code.
Each code needs to become an image: a QR code that links to the redemption page, with the human-readable code printed above it (for the entrants who'd rather type it in than scan). Rendered to a PNG, downloaded, one file per code, named after the code itself — because the printer needs a folder of files they can drop straight into a print template.
import React from "react";
import QRCode from "react-qr-code";
import { saveAs } from "file-saver";
import codes from "./codes.js"; // ["ABC123", "XYZ789", ...]
const BASE_URL = "https://yourdomain.com/redeem/";
const generateQRValue = (code) => `${BASE_URL}${code}`;
const renderCodeToPng = async (code) => {
return new Promise((resolve) => {
// Give react-qr-code a tick to finish rendering into the DOM
setTimeout(() => {
const container = document.createElement("div");
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.alignItems = "center";
const label = document.createElement("p");
label.textContent = code;
label.style.textAlign = "center";
label.style.fontSize = "36px";
label.style.fontWeight = "bold";
label.style.fontFamily = "Arial, sans-serif";
container.appendChild(label);
const qrElement = document.getElementById(`qr-${code}`);
container.appendChild(qrElement.cloneNode(true));
// Wrap the label + QR code in an SVG foreignObject so canvas can rasterize it
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
const foreignObject = document.createElementNS(
"http://www.w3.org/2000/svg",
"foreignObject"
);
foreignObject.setAttribute("width", "100%");
foreignObject.setAttribute("height", "100%");
foreignObject.appendChild(container);
svg.appendChild(foreignObject);
const svgString = new XMLSerializer().serializeToString(svg);
const image = new Image();
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = 500;
canvas.height = 500;
canvas.getContext("2d").drawImage(image, 0, 0, 500, 500);
canvas.toBlob((blob) => {
saveAs(URL.createObjectURL(blob), `${code}.png`);
resolve();
});
};
image.src = `data:image/svg+xml;base64,${btoa(svgString)}`;
}, 300);
});
};
const downloadAllQRImages = async () => {
for (const code of codes) {
await renderCodeToPng(code); // sequential — see note below
}
};
const BulkQRGenerator = () => (
<div>
<button onClick={downloadAllQRImages}>Download all</button>
{codes.map((code) => (
<div key={code} style={{ display: "none" }}>
<QRCode id={`qr-${code}`} value={generateQRValue(code)} />
</div>
))}
</div>
);
export default BulkQRGenerator;
Three things worth calling out:
-
Why SVG → canvas → PNG, and not a direct SVG-to-image library.
react-qr-coderenders an SVG. Browsers won't let youcanvas.drawImage()an SVG element directly — it has to be serialized to a string, base64-encoded, loaded as an<img>, and only then drawn to canvas. That's what theforeignObjectdance is doing. It looks convoluted because the browser genuinely requires it. -
Why the download loop is sequential, not
Promise.all. Every browser caps concurrent downloads triggered byfile-saver— fire a thousand at once and most silently fail or get blocked as a "download spam" pattern. Awaiting each one in aforloop is slower but reliable. For very large batches (tens of thousands), I don't run this in-browser at all — I use a headless Node script withqrcode(the npm package) writing directly to disk, which skips the browser download limitation entirely. The browser version here is for the common case: a few hundred to a few thousand codes for a smaller print run, where nobody wants to spin up a Node script. -
The
setTimeoutis not elegant, butreact-qr-codeneeds a tick to commit the QR pattern to the DOM before it can be cloned. A cleaner version would move to a ref callback instead of a timeout — that's on my list.
What I'd do differently next time
- Move
setTimeoutto a properref-based render callback instead of a fixed delay. - For anything over a few thousand codes, always use the Node/
qrcodepath — I built the in-browser version first and only added the Node path once print runs started hitting tens of thousands of codes. - Add a duplicate-check step in Tool 1 that runs against a live database connection instead of a static export, so the generator itself refuses to produce codes that collide with anything already issued — right now that check happens as a separate step.
The numbers, for context
This isn't a toy. As of this month the platform behind these two scripts has:
- 1,498,385 codes issued, zero duplicates
- 107,218 registrations processed
- 233 countries of residence represented among entrants
- Running continuously since June 2024 across three markets
Both scripts are free to use — clone them, swap the charset and label styling for your own branding, and generate however many codes your print run needs. No paid tool required.
If you've solved bulk QR generation differently — server-side rendering, a different uniqueness strategy, batching for very large print runs — I'd like to hear it in the comments.


Top comments (0)