How to Build a JavaScript QR Code Generator (with Code Examples)
A complete implementation guide for a JavaScript QR code generator: library choice, the exact payload formats for Wi-Fi, vCard, email and SMS, escaping rules, error-correction budget, print sizes, and how to verify the result with a decoder instead of hope.
A QR code generator is a smaller program than it looks. It builds one string, and then it draws that string as a grid of modules. Nine out of ten bugs people blame on the picture are bugs in the string — which is why this guide spends more time on escaping rules than on canvas calls.
If you would rather use the finished thing than build it, the JavaScript QR code generator on this site does everything below in the browser and shows the payload string before it draws anything. Read on for how it works and how to put it in your own project.
What you are actually building
Every QR code is a byte string plus a small amount of metadata. The format defines several encoding modes — numeric, alphanumeric, byte, kanji — and any payload that contains a lowercase letter or a non-ASCII character falls all the way down to byte mode, which is the most expensive one. Your generator's real job is therefore: produce the correct bytes, pick the smallest symbol version that can hold them at the error-correction level you want, and render the resulting module matrix.
That is the entire mental model. Everything else — the payload formats below, the capacity table, the print advice — follows from it.
Choosing a library
Two names dominate, and the difference is not quality but shape.
`qrcode` (npm package `qrcode`) is the one this site uses. It gives you `toCanvas`, `toDataURL`, `toString` for SVG and PNG buffers, and — most useful for anything beyond a demo — `create()`, which returns the module matrix and the chosen version without drawing anything. That second part is what makes an overlay, a capacity readout, or a round-trip test possible without fighting the renderer. It works in the browser and in Node, and types ship as `@types/qrcode`.
`qrcode-generator` (npm package `qrcode-generator`) is older in feel, has no dependencies, and its API is `qrcode(typeNumber, errorCorrectionLevel).addData(...).make().createSvgTag(...)`. It is smaller and it runs anywhere, but it does not give you the raw matrix in a friendly form, so anything involving error-correction budgeting or compositing becomes your problem.
A reasonable rule: use `qrcode` unless you have a hard byte budget. If you are pasting into a plain HTML file from a CDN, `qrcode-generator` is perfectly good for a URL.
The core, in twelve lines
import QRCode from "qrcode";
const payload = "https://jsgenerator.com";
await QRCode.toCanvas(document.querySelector("canvas"), payload, {
width: 512,
margin: 4, // quiet zone, in modules — the ISO minimum is 4
errorCorrectionLevel: "M", // L 7% | M 15% | Q 25% | H 30% recovery
});
const svg = await QRCode.toString(payload, { type: "svg", width: 512, margin: 4 });Nothing about that needs a server. There is no API to call, no key to store, and no user data to move — which matters for the payloads below more than for a URL.
Payload formats, where the work is
A QR code has no type field. The scanner reads the prefix of the string and decides what to offer. Get one character of that string wrong and you still get a beautiful, perfectly scannable symbol that does the wrong thing.
URLs and plain text
Trivial, with one habit worth adding: normalise bare domains to `https://` before encoding, and leave anything that already has a scheme alone. Users type `example.com/pricing`; if you encode that verbatim it becomes text, not a link, and most readers will not open a browser.
Wi-Fi
The format is `WIFI:T:<security>;S:<ssid>;P:<password>;;` and the trap is escaping. Inside SSID and password, these five characters must be backslash-escaped: `\` `;` `,` `:` `"` — and the backslash has to go first, or you escape the escape characters you just inserted.
function escapeWifi(value) {
return String(value)
.replace(/\\/g, "\\\\") // first
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/:/g, "\\:")
.replace(/"/g, '\\"');
}
function buildWifiPayload({ ssid, password, security = "WPA", hidden = false, identity = "" }) {
const parts = ["T:" + security, "S:" + escapeWifi(ssid)];
if (security !== "nopass") parts.push("P:" + escapeWifi(password));
if (security === "EAP") {
if (identity) parts.push("I:" + escapeWifi(identity));
parts.push("E:peap", "A:none");
}
if (hidden) parts.push("H:true");
return "WIFI:" + parts.join(";") + ";;"; // note the trailing double semicolon
}Three details that separate a working generator from a demo: omit `P:` entirely for an open network rather than sending an empty one; end the payload with `;;` because strict readers expect the trailing empty field; and do not invent `T:WPA3` — WPA3-Personal is joined under `T:WPA`, and readers only implement the documented set. Enterprise (802.1X) payloads exist and are inconsistent across phones, so for an event use a separate WPA2-PSK guest network instead.
Also worth saying out loud: a printed Wi-Fi code is a plain-text password. Anyone who photographs the sign has it. Encode a network you can rotate.
vCard
A vCard is a text file with a grammar. Three rules decide whether it imports or lands as an empty contact: lines end with **CRLF**, not LF; both `N:` (the sorted name, `last;first;;;`) and `FN:` (the display name) are present; and `\` `;` `,` are escaped inside values, with a literal line break written as the two characters `\n`.
function escapeVCard(value) {
return String(value)
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r?\n/g, "\\n");
}
function buildVCardPayload({ first = "", last = "", org = "", phone = "", email = "" }) {
const lines = [
"BEGIN:VCARD",
"VERSION:3.0",
"N:" + escapeVCard(last) + ";" + escapeVCard(first) + ";;;",
"FN:" + escapeVCard([first, last].filter(Boolean).join(" ")),
];
if (org) lines.push("ORG:" + escapeVCard(org));
if (phone) lines.push("TEL;TYPE=CELL:" + escapeVCard(phone));
if (email) lines.push("EMAIL;TYPE=INTERNET,PREF:" + escapeVCard(email));
lines.push("END:VCARD");
return lines.join("\r\n");
}Stay on `VERSION:3.0`. Version 4.0 changes property names and adds `UID`/`BDAY` semantics; mixing the two dialects is a reliable way to produce a card that imports as nothing at all.
Email and SMS
Email is a `mailto:` URL, so subject and body are percent-encoded — which is why a 200-character email body can need a version-9 symbol. Roughly one sixth of your printed size goes on escaping overhead. Some Android mail handlers ignore `body=` outright, so anything important belongs on the landing page too.
function buildMailtoPayload({ to, subject = "", body = "", cc = "" }) {
const params = new URLSearchParams();
if (subject) params.set("subject", subject);
if (body) params.set("body", body);
if (cc) params.set("cc", cc);
const query = params.toString();
return "mailto:" + String(to).trim() + (query ? "?" + query : "");
}
const sms = ({ number, message }) =>
message ? "SMSTO:" + number + ":" + message : "smsto:" + number;For SMS, `SMSTO:<number>:<text>` is the form iOS and Android readers both parse. `sms:` is iOS-flavoured, `smsto:` is Android-flavoured — choosing one costs you the other platform. Use E.164 with a leading `+`, and remember the carrier still splits at 160 GSM-7 characters.
Error correction, version, capacity
Error correction is a budget you spend twice: once on surviving damage, once on how much data fits. Level L restores about 7% of codewords and gives you the most capacity; H restores about 30% and gives you the least. Capacity in byte mode, the mode real payloads land in:
| Version | L | M | Q | H | |---|---|---|---|---| | 1 | 17 | 14 | 11 | 7 | | 2 | 32 | 26 | 20 | 14 | | 4 | 78 | 62 | 46 | 34 | | 10 | 271 | 213 | 151 | 119 | | 20 | 858 | 666 | 482 | 382 | | 40 | 2,953 | 2,331 | 1,663 | 1,273 | One correction to a number you will find quoted on many generator sites: the maximum is **2,953 bytes**, not 4,296. 4,296 is version 40 at level L in *alphanumeric* mode, which counts only uppercase ASCII and digits. Copying that figure is harmless in a blog post and misleading in a capacity warning, which is why the table above is generated against the encoder in this repository's test step rather than typed from memory.
Rendering, and the React/Next.js parts
`toCanvas` needs a real canvas, so it cannot run during server-side rendering. In Next.js that means a client component with an effect, and an effect that skips the empty case:
"use client";
import { useEffect, useRef } from "react";
import QRCode from "qrcode";
export function Qr({ payload, size = 256, level = "M" }) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current || !payload) return;
QRCode.toCanvas(ref.current, payload, { width: size, margin: 4, errorCorrectionLevel: level })
.catch((error) => console.error(error));
}, [payload, size, level]);
return <canvas ref={ref} aria-label="QR code" />;
}Two refinements worth the lines. First, if the payload comes from an input, debounce it — re-encoding on every keystroke is wasted work, and a pasted 200-row batch will do it hundreds of times. Second, refuse to draw an empty payload rather than drawing it: an empty string encodes successfully and renders as a blank box, which reads to a user as "the tool is broken".
For a server-rendered page (an invoice PDF, a generated ticket), use the Node API and embed the SVG string. It has no canvas dependency, unlike `toCanvas`, and `QRCode.toString(payload, { type: "svg" })` returns markup you can inline:
import QRCode from "qrcode";
const svg = await QRCode.toString(payload, { type: "svg", margin: 4, errorCorrectionLevel: "M" });Do that instead of `dangerouslySetInnerHTML` on user-controlled data without thinking: an SVG string built from a payload is safe, but if you ever let users supply raw SVG rather than text, you have built an XSS hole with a QR code on top.
Printing is a different problem than drawing
Screens forgive almost everything; paper does not. The number that predicts whether a code works is the **printed edge of one module**, not the pixel count of your PNG:
- Keep the module edge at or above about 0.7 mm. A 29-module image at 27 mm is 2.1 mm per module and scans instantly; a 157-module image at 27 mm is 0.6 mm and will not, even though both were exported at 800 px.
- Keep the quiet zone at 4 modules of true white. A code printed edge-to-edge on a coloured card fails more often than a dense one.
- Keep contrast above roughly 4.5:1, and keep dark modules darker than the background. Inverted (light-on-dark) codes are legal and some readers still refuse them.
- Print one and scan it from the actual distance, on one iPhone and one Android, before the run. Lamination and glossy stock kill more codes than resolution does.
If your users print labels, this arithmetic belongs in the UI. It is the reason the tools here report the module size alongside the export size.
How to know it works
The uncomfortable truth about payload builders is that a wrong one is invisible: the symbol looks correct, and a phone simply rejects it. So test by decoding, not by looking.
// A round-trip assertion, in the shape a CI step wants.
import QRCode from "qrcode";
import jsQR from "jsqr";
const payload = buildWifiPayload({ ssid: "net;work", password: "pa,ss:wo;rd" });
const matrix = QRCode.create(payload, { errorCorrectionLevel: "Q" }).modules;
const decoded = jsQR(toBitmap(matrix), matrix.size, matrix.size);
if (decoded.data !== payload) throw new Error("payload was mangled in transit");That is the whole trick used on this site: `scripts/qr-roundtrip.mjs` builds every payload type, paints the matrix, reads it back with a real decoder, and compares bytes — then it also evaluates the copy-paste code shown on the page and asserts it produces the identical string. If you write a QR feature without a decoder in the loop, you have tested the picture, not the data.
What the tool on this site adds
The QR code generator wraps exactly the builders above with the payload visible before the symbol, byte count, chosen version and module grid, PNG and SVG export, and code you can copy out of the same functions the preview uses. If you need a logo in the middle, or a list of codes turned into label sheets, that is the with-logo tool.
Frequently Asked Questions
Which JavaScript library should I use for QR codes?
Use qrcode (npm package `qrcode`) for almost everything: it renders to canvas, data URL, SVG and Node buffers, and its create() method exposes the module matrix and version, which you need for overlays and capacity reporting. Use qrcode-generator when you want the smallest possible dependency and only need to draw a URL.
How do I escape a Wi-Fi password for a QR code?
Prefix each backslash, semicolon, comma, colon and double quote with a backslash, replacing backslashes first so you do not double-escape. Then build WIFI:T:<security>;S:<ssid>;P:<password>;; and omit the P: field for an open network.
Can I generate QR codes in Next.js server components?
Yes, if you use the SVG string API rather than a canvas: QRCode.toString(payload, { type: "svg" }) runs in Node and returns markup you can inline. toCanvas requires a browser canvas element and therefore belongs in a client component.
What is the maximum data a QR code can hold?
2,953 bytes in byte mode at version 40 with error correction level L. The 4,296 figure often quoted is alphanumeric mode at the same version and level, which only counts uppercase ASCII and digits.
Why does my generated QR code not scan even though it looks right?
Because the picture only reflects the string. The usual causes are an unescaped metacharacter in a Wi-Fi payload, a vCard joined with LF instead of CRLF, a printed module edge below about 0.7 mm, a missing quiet zone, or contrast below 4.5:1. Verify by decoding the rendered symbol back to text in a test, not by looking at it.