DEV Community

Cover image for Building a QR Code Scanner in Next.js with html5-qrcode (Camera + Image Upload)
Shaishav Patel
Shaishav Patel

Posted on

Building a QR Code Scanner in Next.js with html5-qrcode (Camera + Image Upload)

Building a QR code scanner for the browser sounds straightforward. Then you try it and hit three problems fast:

  1. Camera access requires specific permissions handling
  2. The scanning library manipulates the DOM directly — React doesn't like that
  3. Stopping the scanner at the wrong moment throws uncatchable errors

I built a QR Code Scanner with camera feed and image upload support using html5-qrcode. Here's what I ran into and how I fixed each issue.

Choosing html5-qrcode

There are a few options for browser QR scanning:

  • jsQR — pure JS, manual camera loop, no built-in UI
  • ZXing — Java port, heavier bundle
  • html5-qrcode — handles camera, file scanning, and UI scaffolding out of the box

html5-qrcode won because it abstracts the camera permission flow and getUserMedia loop. Less code to own.

npm install html5-qrcode
Enter fullscreen mode Exit fullscreen mode

Basic Architecture

Two scan modes: image upload and live camera. Both use the same Html5Qrcode instance but different methods.

import { Html5Qrcode } from "html5-qrcode";

// Image scanning
const scanner = new Html5Qrcode("container-id");
const result = await scanner.scanFile(file, true);

// Camera scanning
await scanner.start(
  { facingMode: "environment" },
  { fps: 10, qrbox: { width: 250, height: 250 } },
  (decoded) => { /* success */ },
  () => {}  // frame failure — ignore
);
Enter fullscreen mode Exit fullscreen mode

Straightforward on paper. The DOM conflicts make it interesting.

Problem 1: React Can't Remove DOM Nodes Owned by html5-qrcode

The first error: Node.removeChild: The node to be removed is not a child of this node.

Here's what happens. You render the camera container conditionally:

{cameraActive && (
  <div id="qr-camera-feed" />
)}
Enter fullscreen mode Exit fullscreen mode

html5-qrcode starts injecting <video>, <canvas>, and <div> elements inside #qr-camera-feed. Then when cameraActive becomes false, React tries to remove its version of #qr-camera-feed from the DOM — but the DOM no longer matches React's virtual DOM because the library modified it.

The fix: never conditionally unmount the container. Give the library exclusive DOM ownership by keeping the element mounted and using CSS to hide it instead:

{/* Camera prompt — separate element, not inside the feed div */}
{!cameraActive && (
  <div onClick={startCamera} className="...prompt UI...">
    Click to start camera
  </div>
)}

{/* html5-qrcode owns this div entirely — no React children inside */}
<div
  id="qr-camera-feed"
  className={cameraActive ? "block" : "hidden"}
/>
Enter fullscreen mode Exit fullscreen mode

Two key changes:

  1. The "Click to start camera" UI is a separate element — not a child of #qr-camera-feed
  2. The camera container is always mounted, just hidden with display: none when inactive

React never tries to remove nodes from inside the container, so no conflict.

Problem 2: "Cannot stop, scanner is not running"

The second error appeared when a QR code was detected. The success callback fired, which called stopCamera(), which called scanner.stop() — but the scanner was still in the middle of processing the frame.

The solution is a ref-based running flag:

const scannerRef = useRef<Html5Qrcode | null>(null);
const scannerRunningRef = useRef(false);
Enter fullscreen mode Exit fullscreen mode

In startCamera, set the flag after scanner.start() resolves:

await scanner.start(
  { facingMode: "environment" },
  { fps: 10, qrbox: { width: 250, height: 250 } },
  (decoded) => {
    const parsed = parseQrContent(decoded);
    // Guard against double-stop
    if (scannerRunningRef.current) {
      scannerRunningRef.current = false;
      scanner.stop().then(() => {
        scanner.clear();
        scannerRef.current = null;
        setCameraActive(false);
        setResult(parsed);
      }).catch(() => {
        scannerRef.current = null;
        setCameraActive(false);
        setResult(parsed);
      });
    }
  },
  () => {}
);
scannerRunningRef.current = true;
Enter fullscreen mode Exit fullscreen mode

And in stopCamera:

const stopCamera = useCallback(async () => {
  if (scannerRef.current && scannerRunningRef.current) {
    scannerRunningRef.current = false;
    try {
      await scannerRef.current.stop();
      scannerRef.current.clear();
    } catch {
      // Already stopped
    }
    scannerRef.current = null;
  }
  setCameraActive(false);
}, []);
Enter fullscreen mode Exit fullscreen mode

The flag prevents double-stop from both the success callback and any manual "Stop Camera" click. Setting it to false before calling stop() means subsequent calls are no-ops.

Problem 3: Cleanup on Unmount

If the user navigates away while the camera is active, the scanner keeps running. The cleanup effect handles this:

useEffect(() => {
  return () => {
    if (scannerRef.current && scannerRunningRef.current) {
      scannerRunningRef.current = false;
      scannerRef.current.stop().then(() => {
        scannerRef.current?.clear();
        scannerRef.current = null;
      }).catch(() => {
        scannerRef.current = null;
      });
    }
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

The empty dependency array means this only runs on mount/unmount. Always async — stop() returns a Promise and we shouldn't block cleanup.

SSR: html5-qrcode Is Browser-Only

html5-qrcode references navigator, window, and the DOM at import time. It crashes on the server.

// QrCodeScannerLoader.tsx
const QrCodeScanner = dynamic(
  () => import("./QrCodeScanner").then((mod) => mod.QrCodeScanner),
  { ssr: false }
);
Enter fullscreen mode Exit fullscreen mode

ssr: false ensures the component only hydrates in the browser. The page wrapper (SEO content, schema markup) still renders server-side.

Image Upload Scanning

For the image upload mode, html5-qrcode provides scanFile():

const handleImageFile = useCallback(async (file: File) => {
  setScanning(true);
  try {
    const scanner = new Html5Qrcode("qr-scanner-hidden");
    const decoded = await scanner.scanFile(file, true);
    setResult(parseQrContent(decoded));
    scanner.clear();
  } catch {
    setError("No QR code found. Try a clearer image.");
  } finally {
    setScanning(false);
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

Note "qr-scanner-hidden" — a separate hidden div from the camera container. scanFile() also manipulates the DOM temporarily, so it gets its own isolated element:

<div id="qr-scanner-hidden" className="hidden" />
Enter fullscreen mode Exit fullscreen mode

The true parameter in scanFile(file, true) enables verbose mode for better detection on low-quality images.

Parsing QR Content Types

Raw QR code strings follow format conventions. A WiFi QR code looks like:

WIFI:T:WPA;S:NetworkName;P:Password;;
Enter fullscreen mode Exit fullscreen mode

A geo coordinate looks like:

geo:37.7749,-122.4194
Enter fullscreen mode Exit fullscreen mode

Rather than showing users the raw string, parse it into structured data:

function parseQrContent(text: string): ParsedResult {
  const t = text.trim();

  if (/^https?:\/\//i.test(t))
    return { type: "url", raw: t, data: { url: t } };

  const wifiMatch = t.match(
    /^WIFI:(?:T:(.*?);)?(?:S:(.*?);)?(?:P:(.*?);)?(?:H:(.*?);)?;?$/i
  );
  if (wifiMatch)
    return { type: "wifi", raw: t, data: {
      encryption: wifiMatch[1] || "",
      ssid: wifiMatch[2] || "",
      password: wifiMatch[3] || "",
    }};

  if (/^tel:/i.test(t))
    return { type: "phone", raw: t, data: { phone: t.replace(/^tel:/i, "") } };

  if (/^mailto:/i.test(t)) {
    const url = new URL(t);
    return { type: "email", raw: t, data: {
      email: url.pathname,
      subject: url.searchParams.get("subject") || "",
    }};
  }

  if (/^geo:/i.test(t)) {
    const [lat, lng] = t.replace(/^geo:/i, "").split(",");
    return { type: "geo", raw: t, data: { lat, lng } };
  }

  if (/^BEGIN:VCARD/i.test(t)) {
    return { type: "vcard", raw: t, data: {
      name: t.match(/FN:(.*)/i)?.[1] || "",
      phone: t.match(/TEL.*?:(.*)/i)?.[1] || "",
      email: t.match(/EMAIL.*?:(.*)/i)?.[1] || "",
    }};
  }

  return { type: "text", raw: t, data: { text: t } };
}
Enter fullscreen mode Exit fullscreen mode

This lets you show WiFi credentials in a readable grid, render URLs as clickable links, and add a "Open in Google Maps" button for geo coordinates — instead of dumping a raw string at the user.

The Final Component Structure

QrCodeScannerLoader.tsx     — dynamic import wrapper (ssr: false)
QrCodeScanner.tsx           — main component
  ├── #qr-scanner-hidden    — isolated div for scanFile()
  ├── Mode toggle           — Image Upload / Camera Scan
  ├── Image upload zone     — drag & drop
  ├── Camera prompt         — shown when !cameraActive
  ├── #qr-camera-feed       — always mounted, hidden/shown via CSS
  └── Result display        — parsed content with type badge
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Never conditionally unmount a container that a DOM-manipulating library owns — use CSS hide/show instead
  2. Track scanner state with a ref — prevents double-stop race conditions
  3. Give image scanning its own isolated container — separate from the camera feed
  4. Parse QR formats — users shouldn't have to decode WIFI:T:WPA;S:... themselves
  5. Always clean up on unmount — camera streams keep running after navigation without explicit stop()

The tool is live at ultimatetools.io/tools/misc-tools/qr-code-scanner/ if you want to see it in action.


Part of Ultimate Tools — free, privacy-first browser tools built with Next.js and TypeScript.

Top comments (0)