There is a folder on a laptop somewhere in every small town with three files in it: menu.pdf, menu-final.pdf and menu-final2-print.pdf. Nobody knows which one the printer used. One of them is taped inside the window behind a QR code that was generated two years ago, and the code points at a URL that a page builder assigned to the second file, which was deleted when the third one was uploaded.

The shop is not badly run. It has just been handed a file format where "publish a correction" means "upload a new document, get a new address, reprint the sign, hope the old code was not the one that stuck".

I build a one-page site generator for small businesses, and the printed kit that goes with it, so I have been on the wrong end of this for a year. The conclusion I keep arriving at is not about design. It is that a price list is a data record, and everything else — the page, the printed sheet, the sign — is a rendering of that record. Once you accept that, the QR code stops being a thing you regenerate and becomes an interface contract.

Here is the record, the arithmetic that decides whether the code scans, the caching rule that quietly breaks "permanent" URLs, and a reference implementation small enough to read in a sitting.

The record is twelve rows and a date

Not sixty. Twelve is what a person reads while standing in a queue with a bag in one hand — the number is a limit of the reader, not of the format.

{ "name": "Rosie's Greens", "where": "Saturday Market, stall 14", "hours": "Sat 8–1", "pricesAsOf": "2026-11-14", "items": [ { "name": "Tomatoes, heirloom", "unit": "per lb", "price": "4.00", "status": "selling fast" }, { "name": "Sourdough, country loaf", "unit": "each", "price": "7.00", "status": "sold out" }, { "name": "Honey, wildflower", "unit": "12 oz jar", "price": "11.00", "status": "in" } ] } 

Four fields per row, and the fourth one is the one everybody leaves out. A price list without a status cannot answer the question the customer actually asked at eleven o'clock — is it still there — and a price list without a unit re-opens an argument at the till every week: four dollars a pound, a kilo, a bunch, or each?

Two fields in the header do more work than they look like. pricesAsOf is a dated line under the table ("Prices as of Saturday 14 November"), which is the cheapest credibility a small shop can print: it says the list is current without pretending it is permanent. And where is not decoration — a customer who scanned a sign three stalls down needs to know they are in the right place.

Everything else in this article is a function of that object.

Three renderings, one source

The same record should produce, without a second copy of the truth:

  1. The page — one column at the phone's own width, prices before photographs, the date line, the sold-out row, hours, a call button, directions.
  2. The A4 print sheet — @media print, black on white, the same twelve rows, the date line, and a QR code.
  3. The signage — a poster, a table card, a crate sticker: bigger type, fewer words, and the same code in the corner.

The failure mode is one record split into three documents. The page says £7, the printed sheet says £6.50 because it was made in March, and the sign in the window says "see inside" because the owner gave up. Every one of those is a customer who now believes the shop is inconsistent, which is worse than being expensive.

The QR code holds a URL, and the URL is the contract

A QR code holds a string. If the string is the address of a page you control, then the code keeps working for exactly as long as the page lives at that address. That is the entire trick, and it is why the code is the one artefact on the list that never gets reprinted.

Which means the string matters more than the artwork. A QR symbol does not get bigger when the payload gets longer — it gets denser: more modules inside the same square, so each module is smaller, until the printer's floor or the phone camera gives out. You can measure this in ten lines with the same library I use to generate the print files:

import qrcode from qrcode.constants import ERROR_CORRECT_M def squares(payload: str) -> int: """Modules across, plus the four-module quiet zone on both sides.""" qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=0, box_size=1) qr.add_data(payload) qr.make(fit=True) return len(qr.get_matrix()) + 8 for url in ( "https://maptosite-app.pages.dev/s/rosies-greens", # 47 bytes "https://maptosite-app.pages.dev/s/rosies-greens-and-organic-bakery-bus", # 70 "https://maptosite-app.pages.dev/s/rosies-greens?utm_source=qr&utm_medium=sign&utm_campaign=spring-market-2026-week-46", ): print(len(url.encode()), squares(url)) 

At error correction level M — the level these print files use — that prints:

47 41 70 45 117 53 

Forty-one squares, forty-five, fifty-three. The symbol is the same physical size on the paper in all three cases, so the module width is the interesting number:

Printed code 41 squares 45 squares 53 squares
48 mm (table card) 1.17 mm 1.07 mm 0.91 mm
126 mm (A4 poster) 3.07 mm 2.80 mm 2.38 mm

Forty extra characters of tracking parameters cost about 22% of every module. On a laminated card read in morning sun, that is the difference between comfortable and marginal — and it is entirely self-inflicted.

So: keep the printed payload short. A slug of six to ten characters is not vanity. Put the campaign parameters on the links people click (the page's own CTAs, a directory listing, a newsletter), where they cost nothing and can be changed next week.

The boundary is worth knowing because it is sharp. At level M, version 4 holds 62 bytes in byte mode and gives 33 modules; 63 bytes pushes to version 5, 37 modules. One character can cost you four squares.

print(squares("a" * 62)) # 41 squares -> 33 modules + margin print(squares("a" * 63)) # 45 squares -> 37 modules + margin 

(The quiet zone is part of this arithmetic: four modules of white on every side, which is what Denso Wave's FAQ and GS1's 2D barcode guideline both require — four times the module width. Eight extra squares on a 33-module code is a margin you can see.)

What the paper can carry

The sizes I use are measured in the PDFs the kit produces, not estimated from a mock-up: a 93.0 × 127.5 mm table card with a 48 mm code, a 70 mm round sticker with a 40 mm code, an A4 poster with a 126 mm code.

Piece Printed size Code Read from
Table card (quarter A4) 93.0 × 127.5 mm 48 mm ~0.5 m
Round sticker 70 mm 40 mm ~0.4 m
A4 poster 210 × 297 mm 126 mm ~1.26 m
A3, same artwork 297 × 420 mm 178 mm ~1.78 m (arithmetic)
A2, same artwork 420 × 594 mm 252 mm ~2.5 m (arithmetic)

The distances come from the rule of thumb of one unit of code per ten units of distance. It is not in ISO/IEC 18004. It is what signage converges on because it survives a normal phone camera in normal light — treat it as a floor, not a guarantee, and plan for 1:8 under gloss or in low sun.

Two consequences that surprise people:

  • At 1:10, those sizes sit exactly on the rule. The 48 mm card is for 0.48 m. If the sign has to be read from three metres, the arithmetic wants about 300 mm of code, which on a 420 mm-wide A3 leaves 60 mm of paper on each side — a code with a name squeezed above it and nothing else. The better answer is a second card at the front edge of the table, not a bigger poster.
  • Print at 100%. A4 artwork on US Letter is scaled to about 94%, and every module shrinks with it. This is the single most common cause of "the code worked on my desk and not on the counter".

And the floor on the other side: roughly 0.17 mm per module for office printers, with four printer dots per module recommended (0.33 mm at 300 dpi). At 41 squares, a 48 mm code is 1.17 mm per module and a 126 mm poster is 3.07 mm — both well clear. If those numbers ever get close, the answer is a shorter payload or a bigger code, not a different generator.

The caching rule that breaks "permanent"

This part is specific to the web people reading this, and it is the one that turns a good design into a support ticket six weeks later.

A permanent URL means the document at that URL changes while the URL stays the same. That is the exact opposite of the caching advice you get from every performance audit: "serve HTML with immutable, max-age=31536000". Do that on a price list and a returning customer sees last week's tomatoes for a year, because the browser never asks again.

On the deployment I run (Cloudflare Pages), the headers on the guide page are:

cache-control: public, max-age=0, must-revalidate content-type: text/html; charset=utf-8 # /assets/app.css cache-control: public, max-age=600 etag: "8c012a3ba5accf592fd58d785a023d1d" 

That split is the whole idea: the HTML is never immutable, the assets behind it can be. If your CSS and JS carry a content hash in the filename, cache them for a year and forget them; the page itself keeps a revalidation window, and a price change from a phone at seven in the morning is live by the time the stall opens.

Two corollaries I have paid for:

  • Do not version the page. menu-v2 gets you a second URL, which is a second code, which is the reprint you were trying to avoid. Version the assets; freeze the page address.
  • Do not put the payload behind a redirect you cannot control. A shortener that expires, or a link to a file in someone else's drive, is a printed code with an expiry date that nobody wrote down.

A reference implementation, about forty lines

No framework, no build step, no JavaScript required to read the table. The record is injected at build time (or fetched once and cached); the important part is that the table is plain HTML when it reaches the phone.

const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); const money = (p) => (p == null ? "—" : `$${Number(p).toFixed(2)}`); function priceTable(record) { const rows = record.items.slice(0, 12).map((it) => { const out = String(it.status || "").toLowerCase() === "sold out"; return `<tr${out ? ' class="out"' : ""}> <td>${esc(it.name)}</td> <td class="u">${esc(it.unit)}</td> <td class="n">${esc(money(it.price))}</td> <td class="s">${esc(it.status || "in")}</td> </tr>`; }).join(""); return `<table class="prices"> <caption>Prices as of ${esc(record.pricesAsOf)}</caption> <thead><tr><th>Item</th><th>Unit</th><th>Price</th><th></th></tr></thead> <tbody>${rows}</tbody> </table>`; } 

The CSS that makes it survive contact with a phone and a printer is more interesting than the renderer:

.prices { width: 100%; border-collapse: collapse; font-size: 15px; } .prices td, .prices th { padding: 10px 8px; text-align: left; } .prices td.n, .prices th:nth-child(3) { text-align: right; font-variant-numeric: tabular-nums; } .prices tr.out td { color: #8a8f98; text-decoration: line-through; } .prices caption { text-align: left; font-size: 12.5px; color: #6b7280; padding-bottom: 6px; } @media (max-width: 420px) { .prices { font-size: 14.5px; } .prices td.u { white-space: nowrap; } } @media print { @page { size: A4; margin: 12mm; } .prices { font-size: 11pt; } .prices tr.out td { color: #555; } * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } } 

tabular-nums on the price column is not a detail: a column of prices with proportional digits looks like it was typed by three different people, and the whole point of this page is that it looks like one list.

The date line belongs in the caption, not in a paragraph above the table, so that copying the table into a message or a print view takes the date with it.

The test that actually predicts failure

Not "does it scan". Everything scans in your hand over a desk at midnight. The test that predicts what happens on Saturday:

  1. Compute the module width before printing: code_mm / (modules + 8). If it is under 0.33 mm, the piece is a bet, and a 300 dpi printer will make it a bad one.
  2. Print one piece at 100% and measure the code with a ruler. If the 48 mm code measures 45 mm, you printed fit-to-page, and every module is 6% smaller than the arithmetic said.
  3. Scan it with two phones, one iPhone and one Android, from where a customer actually stands — outside, at the hour you open.
  4. Laminate matte. Gloss under a market's morning sun throws a highlight exactly where the phone is pointing, which is why a sign can fail at nine and work at two.
  5. Leave the quiet zone empty. No logo, no "scan me" arrow, no border line crossing the four modules.

I have watched a well-designed A4 fail all five and a plain black-on-white table pass all five, printed on a $90 laser printer at a copy shop. The design was not the variable. The arithmetic was.

What this does not do

Honesty is cheaper than a refund:

  • It is not a booking system. Calendars, deposits, reminder texts and no-show protection are a separate product and worth paying for. The page owns the place the booking link lives; it does not hold the calendar.
  • It is not a shop. No checkout, no stock counts, no shipping.
  • It does not replace the chalkboard. The board is read by the person already standing there, at one metre, in the sun, with a bag in one hand. The page is for the person still at home at 7:40 deciding whether the trip is worth it. Split by distance, not by preference.

What it costs, with the list prices

Read on the vendors' own pages on 14 November 2026, before tax, annual billing where stated:

Route Per month Three years
Linktree Pro (annual) $14 $504
Wix Light (yearly) $17 $612
Squarespace Basic (annual) $19 $684
A static page on a free host $0 $0

Disclosure: I build MapToSite, and the page side is free — no card, no account, an address like /s/your-shop and an edit key. The paid tiers are $6.99 a month for a photo gallery and a notice line, $13.99 for a domain of your own, billed monthly through Polar. The printed kit is $49 once — fifteen printed pieces for a shop with tables, eight for one without — and it exists because the arithmetic in this article is what it does automatically.

The monthly plans are not silly. They buy an editor, a shop, a blog and a domain, and if you sell online in winter or run a subscription box they are the right purchase. The question is narrower: does a shop need to rent an editor for three years to publish twelve rows and one link? The calculator does that comparison with your own numbers, including the cases where the monthly plan wins.

Two worked examples, if you want the long versions

The short version

A price list is a JSON object with twelve rows, a date and a place. Render it to a page, render it to paper, and freeze the address the QR code points at — because the code is the only thing on the list you cannot edit after it is printed. Keep the payload short, keep the HTML revalidating, print at 100%, and measure the module width before you spend an afternoon at the copier.

Everything else is typography.