QR code generation is a good example of a task that does not always need a backend.
For a basic static QR code, the browser can take a URL or text, encode it, draw the result to a <canvas>, and let the user download it.
No QR-generation API is required.
In this article, we will build a small client-side QR generator with JavaScript.
What we are building
The flow is deliberately simple:
User input
↓
JavaScript
↓
QR encoder
↓
Canvas
↓
PNG download
The important part is that the entered text does not need to be sent to a server just to create the QR code.
1. Install the QR library
We will use the qrcode package.
npm install qrcode
Then import it into your JavaScript:
import QRCode from "qrcode";
2. Create the HTML
We only need an input, a button, a canvas, and a download button.
<div class="qr-tool">
<label for="qr-input">URL or text</label>
<input
id="qr-input"
type="text"
placeholder="https://example.com"
/>
<button id="generate">
Generate QR
</button>
<canvas id="qr-canvas"></canvas>
<button id="download">
Download PNG
</button>
</div>
Nothing here requires an account, database, or API endpoint.
3. Generate the QR code
Now connect the interface to the QR library.
import QRCode from "qrcode";
const input = document.querySelector("#qr-input");
const canvas = document.querySelector("#qr-canvas");
const generateButton = document.querySelector("#generate");
generateButton.addEventListener("click", async () => {
const value = input.value.trim();
if (!value) {
alert("Enter a URL or some text first.");
return;
}
try {
await QRCode.toCanvas(canvas, value, {
width: 280,
margin: 3,
errorCorrectionLevel: "M"
});
} catch (error) {
console.error("QR generation failed:", error);
}
});
When the button is clicked, the value is passed directly to the QR encoder and rendered on the canvas.
There is no fetch() request and no form submission involved in the QR-generation step.
4. Add PNG downloading
Because the QR code is already rendered to a canvas, downloading it is straightforward.
const downloadButton = document.querySelector("#download");
downloadButton.addEventListener("click", () => {
const dataUrl = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.href = dataUrl;
link.download = "qr-code.png";
link.click();
});
The browser converts the canvas to a PNG data URL and downloads it locally.
5. Add a little validation
If this is going into a real project, it is worth preventing empty QR codes.
function getInputValue() {
const value = input.value.trim();
if (!value) {
throw new Error("Input cannot be empty.");
}
return value;
}
Then update the generator:
generateButton.addEventListener("click", async () => {
try {
const value = getInputValue();
await QRCode.toCanvas(canvas, value, {
width: 280,
margin: 3,
errorCorrectionLevel: "M"
});
} catch (error) {
alert(error.message);
}
});
Browser-based does not automatically mean private
There is an important distinction here.
Generating the QR code locally does not automatically make the entire website privacy-friendly.
For example, this QR generation could still be combined with:
fetch("/analytics", {
method: "POST",
body: JSON.stringify({
qrContent: input.value
})
});
The QR itself would still be generated in the browser, but the input would also be transmitted somewhere else.
So the accurate claim is:
The QR-generation process can happen locally in the browser.
Whether the complete website respects privacy depends on everything else it loads and sends.
Error correction
The library also lets us control QR error correction.
await QRCode.toCanvas(canvas, value, {
errorCorrectionLevel: "H"
});
Common levels are:
| Level | General idea |
|---|---|
| L | Lower correction, more capacity |
| M | Balanced default |
| Q | Higher correction |
| H | Highest correction |
Higher error correction can be useful when a QR code may be damaged or partially covered, but it also increases the amount of information the code needs to store.
For an ordinary QR code shown on a screen, M is a sensible starting point.
Do not remove all the margin
It is tempting to make the code fill every available pixel:
margin: 0
I would avoid that.
QR scanners work more reliably when there is clear empty space around the code.
Something like this is safer:
margin: 3
Complete JavaScript
Here is the finished version:
import QRCode from "qrcode";
const input = document.querySelector("#qr-input");
const canvas = document.querySelector("#qr-canvas");
const generateButton = document.querySelector("#generate");
const downloadButton = document.querySelector("#download");
function getInputValue() {
const value = input.value.trim();
if (!value) {
throw new Error("Enter a URL or some text first.");
}
return value;
}
generateButton.addEventListener("click", async () => {
try {
const value = getInputValue();
await QRCode.toCanvas(canvas, value, {
width: 280,
margin: 3,
errorCorrectionLevel: "M"
});
} catch (error) {
alert(error.message);
}
});
downloadButton.addEventListener("click", () => {
const dataUrl = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.href = dataUrl;
link.download = "qr-code.png";
link.click();
});
For a basic static QR generator, that is really all we need.
No QR-generation backend.
No database.
No account system.
Just browser input, JavaScript, canvas rendering, and a downloadable result.
I have been applying the same principle while building Tryst Link: when a small task can be handled locally, adding another server request should have a clear reason.
The wider project covers QR systems, URLs, privacy, cybersecurity, apps, and practical web engineering:
Sometimes the cleanest architecture is not about adding another service. It is about recognizing which services were never necessary.