← Back to blog

Browser-to-Desktop Printing with react-qztray

·
react typescript printing open-source qz-tray qz.io

Browser printing has always been the awkward corner of web development. window.print() gives you a print dialog and a prayer. For consumer sites that’s fine. For warehouse systems, production floors, or anything that needs to talk directly to label printers on a predictable schedule, it falls apart immediately.

QZ Tray solves this by running a small desktop application that opens a WebSocket server on the user’s machine. Your web app connects to it, sends structured print jobs, and QZ Tray handles the actual printer communication. No browser dialog, no PDF detour, no guessing about paper size.

react-qztray is a hooks library I built to make this integration less painful in React.

What the user needs

QZ Tray is a desktop application the end user installs once. It runs in the background and listens at wss://localhost:8181 by default.

In production, every request must be cryptographically signed. QZ Tray verifies the signature against a certificate you provide, so you need two things:

  1. A certificate/key pair generated by QZ Tray’s signing tool
  2. A backend endpoint that signs requests on behalf of your frontend

The signing request is a simple HTTP call with a string in and a string out, so the backend side is minimal.

Installation

npm install react-qztray
# or
pnpm add react-qztray

QZ Tray’s own JavaScript library is bundled internally, so there’s no separate qz-tray.js install.

Setting up request signing

This is the part most tutorials skip and where most integrations get stuck.

QZ Tray passes a string to your signaturePromise function. You send that string to your backend, your backend signs it with the private key and returns the signature, then QZ Tray verifies it against the public certificate.

A minimal NestJS signing endpoint:

import { Controller, Post, Body } from '@nestjs/common';
import { createSign } from 'node:crypto';
import { readFileSync } from 'node:fs';

@Controller('qztray')
export class QzTrayController {
  private readonly privateKey = readFileSync('./certs/qztray-private.pem', 'utf8');

  @Post('sign')
  sign(@Body('toSign') toSign: string): string {
    const sign = createSign('SHA512');
    sign.update(toSign);
    return sign.sign(this.privateKey, 'base64');
  }
}

The private key stays on the server. The public certificate goes in your React app and is safe to expose.

QzTrayProvider

QzTrayProvider sets up the context for all child hooks. Wrap it around whichever part of your app needs printing, whether that’s the root or a specific route.

import { QzTrayProvider } from 'react-qztray';

const certificate = `-----BEGIN CERTIFICATE-----
YOUR_PUBLIC_CERT_HERE
-----END CERTIFICATE-----`;

export const Root = () => (
  <QzTrayProvider
    certificate={certificate}
    signatureAlgorithm="SHA512"
    signaturePromise={(toSign) => (resolve) => {
      fetch('/api/qztray/sign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ toSign }),
      })
        .then((res) => res.text())
        .then(resolve);
    }}
  >
    <App />
  </QzTrayProvider>
);

The signaturePromise shape looks unusual at first. It’s a curried factory: receives toSign, returns a function that receives resolve. This is how QZ Tray’s internal API works, so the library matches it directly.

If you need to fetch the certificate at runtime rather than hardcoding it, certificate also accepts an async function:

certificate={async () => {
  const res = await fetch('/api/qztray/certificate');
  return res.text();
}}

useQzTray

useQzTray exposes connection state and the connect/disconnect controls.

import { useQzTray } from 'react-qztray';

const ConnectionStatus = () => {
  const { isConnected, isConnecting, error, connect, disconnect } = useQzTray();

  if (isConnecting) return <span>Connecting to QZ Tray...</span>;
  if (error) return <span>Connection failed. Is QZ Tray running?</span>;

  return isConnected ? (
    <button onClick={disconnect}>Disconnect</button>
  ) : (
    <button onClick={connect}>Connect to printer</button>
  );
};

For dedicated print workstations where QZ Tray is always running, autoConnect on the provider saves the manual connect step:

<QzTrayProvider autoConnect {...rest}>

connect() will throw if QZ Tray isn’t running. Wrap it in a try/catch when calling it manually, or just let the error state surface it to the user.

useQzPrint

useQzPrint submits the actual jobs. It connects automatically if the WebSocket isn’t open yet, so calling connect() first is optional.

import { useQzPrint } from 'react-qztray';

const { print, isPrinting, error } = useQzPrint();

HTML content

If you’re composing content in HTML, the pixel type with html format handles it:

const handlePrint = () => {
  print({
    printer: 'Microsoft Print to PDF',
    config: {
      size: { width: 210, height: 297 }, // A4 in mm
      units: 'mm',
    },
    data: [
      {
        type: 'pixel',
        format: 'html',
        flavor: 'plain',
        data: `
          <div style="font-family: Arial; padding: 20px;">
            <h1>Order #12345</h1>
            <p>Customer: Kowalewski</p>
            <p>Items: 3</p>
          </div>
        `,
      },
    ],
  });
};

PDFs

Swap the format and point data at a URL or base64 string:

// from URL
data: [{ type: 'pixel', format: 'pdf', flavor: 'file', data: 'https://example.com/invoice.pdf' }]

// from base64
data: [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfBase64String }]

Raw ZPL for label printers

For Zebra printers (ZPL) or receipt printers (ESC/POS), use type: 'raw'. QZ Tray passes the commands straight to the printer without any rendering step:

print({
  printer: 'ZDesigner ZT230-203dpi ZPL',
  data: [
    {
      type: 'raw',
      format: 'command',
      flavor: 'plain',
      data: `
        ^XA
        ^FO50,50^ADN,36,20^FDOrder #12345^FS
        ^FO50,100^ADN,24,14^FDKowalewski^FS
        ^FO50,140^BQN,2,5^FDQA,https://example.com/order/12345^FS
        ^XZ
      `,
    },
  ],
});

The printer name must match exactly what the OS reports, including model number and driver suffix. The safest way to get it right is to open any print dialog on the target machine and copy the name verbatim.

Chaining multiple jobs

By default print() disconnects after each job. When printing in bulk that adds a WebSocket handshake on every label, which gets slow. Set autoDisconnect: false on each job except the last:

const printBatch = async (orders: Order[]) => {
  for (let i = 0; i < orders.length; i++) {
    await print({
      printer: 'ZDesigner ZT230',
      autoDisconnect: i === orders.length - 1,
      data: [{ type: 'raw', format: 'command', flavor: 'plain', data: buildZpl(orders[i]) }],
    });
  }
};

A few production gotchas

Certificate expiry. The signing certificate has an expiry date. When it goes stale, QZ Tray can no longer verify the signature and falls back to showing a manual approval popup on every print job. Users have to click through it each time until you rotate the certificate. Set a calendar reminder well before it expires.

Printer name drift. OS printer names change when drivers update or printers get renamed. Hardcoded names will break silently. Store them in user settings or pull the list from qz.printers.find() and let users pick from a dropdown.

Error shapes. The error state from both hooks is typed as unknown because QZ Tray can throw plain strings, Error objects, or its own error shapes. Normalize before displaying to users rather than assuming .message exists.

Skipping signing locally. You can bypass the signing setup in development by passing a null certificate and an identity signature promise. Without a valid signature, QZ Tray shows a popup on every print request asking the user to approve it manually. That’s fine when you’re the one testing, but completely unusable in production where printing needs to happen without any user interaction.

For more working examples covering HTML, PDF, raw ZPL, and chaining multiple jobs, check the examples directory in the repo. There’s also a Vite playground you can run locally to test print jobs against a real QZ Tray instance without wiring up a full app.