# spoko labels: labels printed straight from the browser

Printing one label from the inventory took 5 to 10 minutes: an export, a phone, the NIIMBOT app. Now the panel prints it with one button in under 10 seconds, because the browser talks to the printer directly, over Web Bluetooth. An open-source toolkit, built in three days.

**Canonical:** https://spoko.space/print-labels-from-the-browser/  
**Language:** en  
**Published:** 2026-10-15  
**Tags:** typescript, web-bluetooth, open-source, filament, qr-code  
**Category:** Portfolio  
**GitHub:** https://github.com/spokospace/spoko-labels

---
<div class="text-center flex gap-4 justify-center flex-wrap">

</div>

## At a glance {#at-a-glance}

|  |  |
| --- | --- |
| Problem | Printing a label from the inventory meant an export, a phone and the NIIMBOT app. 5 to 10 minutes, however many labels. |
| Solution | The browser talks to the printer directly, over Web Bluetooth. The panel is left with one button. |
| Result | A label in under 10 seconds, from the phone or the laptop, with nothing exported. |
| Stack | TypeScript, Web Bluetooth, Filament, Cloudflare Pages. |
| Time | Three days from the idea to a button in the panel, checked on a real printer from a laptop, a desktop and a phone. |

## About the project {#about-the-project}

Every box, shelf and part in the garage inventory carries a QR label. They come out of a NIIMBOT B1, a budget printer under 100 PLN (about €25): Bluetooth, 50×30 mm labels, a battery. It fits in a hand, sits on a shelf between the boxes and has no cable at all. The labels are fine. The trouble was how much had to happen before one came out.

spoko labels is an open-source toolkit that lets a web page print on the B1 directly. It was built so the inventory panel could print a label with one button, and along the way it became a public web app for anyone with the same printer.

## The challenge {#the-challenge}

The B1 only talks over Bluetooth to NIIMBOT's phone app, and the desktop app connects over a USB cable only. So every label had to go through the NIIMBOT app. At first most of the time went into formatting: fitting the name, the model, the ID and a QR code onto 50×30 mm so it stayed readable. Once there was a template, the data had to be typed in label by label. As the list grew, an xls import took over:

**The old way**

1. **Export** — Select records in the panel and export them to an xls file.
2. **Transfer** — Get the file onto the phone.
3. **Import** — Import it into the NIIMBOT app and bind the columns to the template.
4. **Print** — Connect to the printer from the app, find the label and print.

Four steps, two devices and 5 to 10 minutes, whether it was one label or twenty. The app has few fonts with Polish characters, and editing on a phone gets tedious past a handful of labels. In practice a label got printed "when there was a moment", and there never was one. Boxes stayed unlabelled and tidying the inventory stalled.

<div class="grid grid-cols-2 gap-4">

</div>

## How I solved it {#how-i-solved-it}

Instead of improving the process, I removed it. A browser can speak Bluetooth just like the phone app does; it only needs to know the printer's language. Nobody documents it, so a Python prototype came first and proved the protocol on a real B1. Then I ported it to TypeScript as four packages and an app, each usable on its own:

| Part | What it does |
| --- | --- |
| `labels-core` | The printer protocol and the Web Bluetooth connection: printer and battery status, density, copies, calibration, aborting a print. |
| `labels-render` | Label templates in millimetres (text, QR code, lines) with fields bound to data, rendered to the image the head prints. |
| `labels-niimbot` | Reads templates designed in the NIIMBOT app, so existing designs keep working. |
| `labels-embed` | A printer status chip and a print dialog to drop into any web app. |
| The web app | A built-in template or one imported from the NIIMBOT app, an xlsx file or a JSON API, pick rows, print. English and Polish. |

### The preview is the print {#the-preview-is-the-print}

The head is 384 dots wide, 8 dots per millimetre, and prints only black and white. A label that looks right on screen and comes out with a smudged QR code is worse than no preview at all, so the renderer works in printer dots:

- **Templates in millimetres, output in dots.** The image is thresholded to pure black and white, and QR modules always land on whole dots, so codes scan even at small sizes.
- **The preview shows the hardware.** The red bands are outside the head and will not print, the grey corners are outside the rounded label, the dashed line is the safe area. Text that does not fit gets a note before you waste a label.
- **Golden-image tests.** A change that moves one dot is a deliberate decision, with before/after images in the pull request.

### Bluetooth from a web page {#bluetooth-from-a-web-page}

Web Bluetooth has rules, and the project is built around them:

- **The connection starts from a click.** The browser only lets you pick a printer in response to a user gesture, so the chip in the print dialog does that first.
- **One command at a time.** The printer answers one command before it takes the next. Everything goes through a single queue, so the chip, the dialog and the app never talk over each other.
- **A protocol checked on hardware.** Packets are `55 55 CMD LEN DATA… CHK AA AA` with an XOR checksum, with status packets the printer sends on its own in between. Every protocol change is checked on a real B1 before it merges.
- **It does not work everywhere, and the app says so.** Chrome and Edge on desktop and Android work. iOS and Firefox have no Web Bluetooth. Brave needs a flag, and the app names it.

### One button in the panel {#one-button-in-the-panel}

The inventory runs on Filament. The embed turned printing into a single action: the panel passes a template and the selected rows, the dialog renders the preview, the chip holds the connection. The package fetches and sends nothing, so it works under a strict Content Security Policy without exceptions. On a phone the chip collapses to an icon in the top bar. The old print view and the export it relied on are gone.

A label printed this way ends up on the part, next to the manufacturer's sticker:

> Printing from the phone works perfectly. I don't have to export xls files any more.
> — the workshop, on the first day of printing from the panel

## Results {#results}

- **A label in under 10 seconds instead of 5 to 10 minutes.** Open the record, tap **Print label**, peel it off. From the phone in the workshop or from the laptop.
- **Nothing exported.** The spreadsheet that went stale as soon as a name changed no longer exists. The label always shows what the panel has.
- **Tidying moved again.** When a label costs nothing, a box gets one right away, not "when there is a moment".
- **Data stays in the browser.** xlsx files are read on the page, an API is fetched with no credentials, nothing reaches any server.
- **A public app.** Anyone with a B1 can print from their own spreadsheet at [spoko-labels.pages.dev](https://spoko-labels.pages.dev), and the code is MIT licensed. Anyone who already has templates in the NIIMBOT app points the page at its database file: the templates load in the browser, with a preview and a list of what was simplified, and stay there for the next visit. That is how the inventory's templates came over.

## How it was built {#how-it-was-built}

From the idea to the button in the panel took three days, 12 to 14 October 2026, in about thirty small pull requests. The port was planned as written specs, one per package. Each package landed with its own tests, 666 of them by the end, and anything that touched the hardware was also checked on a real printer, from a laptop, a desktop and a phone. The validation gate (typecheck, lint, tests, build) runs locally before every merge, and the app is deployed by hand to Cloudflare Pages, with the commit it was built from in the footer.

## What is next {#what-is-next}

- **Template files.** Your own template as a JSON file to load, and an imported one to download, so it can move to another app.
- **Deep links**, so a link opens the app with a template and a data source already chosen.
- **More printers.** Development happens on the B1. Other NIIMBOT models speak a similar protocol, and reports are welcome.
