Build your own QR generator

Most online QR tools send your input to a server. They do not need to. QR encoding is a local mathematical operation, and this guide shows how the live Blackout generator does it with no server, no CDN, and no uploads.

← Back to the QR tool

If you just need one, use our free QR code generator.

Why this matters

When you use an online QR generator, you are submitting your input to someone else's server. That server logs requests. It may retain what you encoded. The operator may sell it. A breach may expose it.

What people encode into QR codes is often not public information. People encode internal URLs, Wi-Fi passwords, contact details, account recovery links, and two-factor backup codes. None of that needs to leave your device to become a QR code. The format is a mathematical transformation that runs in milliseconds, entirely in a browser tab, with zero network activity required.

A server adds nothing to QR generation except a log entry somewhere.


What the live Blackout implementation does

Runs entirely in the browserText is converted into QR modules locally using qr-kazuhiko.js.
Uses bundled local scriptsNo CDN fetch is required for the generator to work.
Renders to canvas locallyThe QR code is drawn in-browser and downloaded as PNG or JPG directly from the canvas element.
Keeps styling local tooColors, gradients, module shapes, and the named presets are all applied client-side, without any network call.
Applies scan-safety checks in-browserUnsafe color combinations are adjusted locally to stay readable.
JPG export composites locallyJPG downloads are flattened onto white in-browser before encoding. No upload involved.

Minimal implementation

The example below mirrors the current Blackout implementation direction more closely than the older template. It still stays simple: one HTML file, local scripts, local canvas rendering, and no server.

This version uses a bundled QR engine and a small render function, rather than outsourcing generation to a remote API or CDN.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>QR Generator</title>
  <style>
    body {
      background: #111;
      color: #ddd;
      font-family: sans-serif;
      max-width: 460px;
      margin: 3rem auto;
      padding: 1rem;
    }
    input {
      width: 100%;
      padding: .65rem;
      background: #1a1a1a;
      border: 1px solid #333;
      color: #ddd;
      border-radius: 8px;
      font-size: 1rem;
      box-sizing: border-box;
    }
    button {
      margin-top: .7rem;
      padding: .6rem 1rem;
      background: #f3eee2;
      color: #050505;
      border: 0;
      border-radius: 8px;
      cursor: pointer;
      font-weight: 600;
    }
    #output {
      margin-top: 1.5rem;
      text-align: center;
    }
    canvas {
      border-radius: 14px;
      box-shadow: 0 18px 42px rgba(0,0,0,0.42);
    }
  </style>
  <script src="./qr-kazuhiko.js"></script>
</head>
<body>
  <h2>QR Generator</h2>
  <p>Local only. Nothing leaves this device.</p>
  <input type="text" id="qr-input" value="https://www.blackoutvpn.io" />
  <input type="color" id="fg" value="#f3eee2">
  <input type="color" id="bg" value="#050505">
  <button onclick="generate()">Generate</button>
  <button id="dl-btn" onclick="downloadQr()" disabled>Download PNG</button>
  <button onclick="setFormat('png')">PNG</button>
  <button onclick="setFormat('jpg')">JPG</button>
  <div id="output"></div>

  <script>
    var SCALE = 12;
    var QUIET = 4;
    var currentCanvas = null;
    var downloadFormat = 'png';

    function utf8ByteString(str) {
      return unescape(encodeURIComponent(str));
    }

    function buildQr(text) {
      var api = window.BlackoutQRCode;
      var qr = new api.QRCode(0, api.QRErrorCorrectLevel.M);
      qr.addData(utf8ByteString(text));
      qr.make();
      return qr;
    }

    function renderCanvas(qr, fg, bg) {
      var modules = qr.getModuleCount();
      var total = (modules + QUIET * 2) * SCALE;
      var canvas = document.createElement('canvas');
      canvas.width = total;
      canvas.height = total;
      var ctx = canvas.getContext('2d');

      ctx.fillStyle = bg;
      ctx.fillRect(0, 0, total, total);
      ctx.fillStyle = fg;

      for (var row = 0; row < modules; row++) {
        for (var col = 0; col < modules; col++) {
          if (!qr.isDark(row, col)) continue;
          ctx.fillRect((col + QUIET) * SCALE, (row + QUIET) * SCALE, SCALE, SCALE);
        }
      }

      return canvas;
    }

    function generate() {
      var text = document.getElementById('qr-input').value.trim();
      if (!text) return;
      var fg = document.getElementById('fg').value;
      var bg = document.getElementById('bg').value;
      var out = document.getElementById('output');
      out.innerHTML = '';
      currentCanvas = renderCanvas(buildQr(text), fg, bg);
      out.appendChild(currentCanvas);
      document.getElementById('dl-btn').disabled = false;
    }

    function setFormat(fmt) {
      downloadFormat = fmt;
      document.getElementById('dl-btn').textContent = 'Download ' + fmt.toUpperCase();
    }

    function downloadQr() {
      if (!currentCanvas) return;
      var a = document.createElement('a');
      if (downloadFormat === 'jpg') {
        var flat = document.createElement('canvas');
        flat.width = currentCanvas.width;
        flat.height = currentCanvas.height;
        var ctx = flat.getContext('2d');
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, flat.width, flat.height);
        ctx.drawImage(currentCanvas, 0, 0);
        a.download = 'qrcode.jpg';
        a.href = flat.toDataURL('image/jpeg', 0.92);
      } else {
        a.download = 'qrcode.png';
        a.href = currentCanvas.toDataURL('image/png');
      }
      var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
      if (isIOS) { window.open(a.href, '_blank'); return; }
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
    }

    document.getElementById('qr-input').addEventListener('keydown', function(e) {
      if (e.key === 'Enter') generate();
    });

    generate();
  </script>
</body>
</html>

Download the template Starter file using qr-kazuhiko.js. Includes foreground/background color pickers, PNG and JPG download, and live re-render on color change. No build step required. You need both files in the same folder. The template will not generate anything without the library beside it.

Download template Download qr-kazuhiko.js

How to verify it

Before trusting any QR tool with sensitive data, verify it yourself. This takes under two minutes.

  1. Open the page in your browser. Open DevTools with F12, or right-click anywhere and choose Inspect.
  2. Go to the Network tab. Clear any existing entries so you are starting from zero.
  3. Type something into the input and generate a QR code.
  4. Watch the Network tab. A clean local generator produces no new requests. Nothing leaves the browser.
  5. Optional: open the Sources tab and inspect the page source. Confirm there are no external script tags, analytics snippets, or tracking pixels.

If you see outbound requests fire when you generate a code, the tool is sending your input somewhere. Stop using it for anything sensitive.


What breaks the privacy model

Common patterns that compromise otherwise client-side tools:

API-based generationThe browser sends your text to a server endpoint. The QR image is rendered server-side and returned. Your input is logged in the process.
CDN script loadingLoading a library from an external CDN tells that CDN your IP address and the page context it was loaded from.
AnalyticsMany tools that generate locally still attach usage tracking.
GET parameter inputsIf your text is passed as a query string, it lands in logs all over the request path.
Built-in link shorteningAny feature that wraps your URL in a short link is sending it to a third party.

If a tool can run locally, run it locally. Every external dependency you accept on behalf of a user is a trust decision you made for them. Remove the ones that add nothing.