We ship four small utilities on pub-trivia.app/tools. Two of them generate files: a printable scoresheet PDF and a QR code. Neither has a server route. No upload, no queue, no temp bucket, no cleanup job.
That is not minimalism for its own sake. It buys three specific things, and it costs one specific thing. Here is the whole trade.
The PDF one
const download = async () => {
const { jsPDF } = await import('jspdf')
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
// draw pages
doc.save(`${quizName}-scoresheets.pdf`)
}
jspdf runs in the browser perfectly well, and A4 in millimetres is a coordinate system you can hold in your head:
const A4_WIDTH_MM = 210
const A4_HEIGHT_MM = 297
const MARGIN_MM = 15
The only arithmetic with any subtlety in it is fitting a variable number of answer lines onto one page without running off the bottom:
const lineGap = Math.min(12, (A4_HEIGHT_MM - MARGIN_MM - y - 10) / config.questionsPerRound)
Twelve millimetres is the comfortable spacing for handwriting. When there are too many questions for that, the gap shrinks to whatever fits, rather than paginating. A round's answers on one sheet is the thing quiz hosts actually need, and a sheet with tighter lines is a much smaller problem than a round split across two pages when the teams are collecting them.
Try it: six rounds of ten with twelve teams is the default, and you get a PDF straight away.
The QR one
const QRCode = (await import('qrcode')).default
const dataUrl = await QRCode.toDataURL(target, { width: 512, margin: 2 })
The same qrcode package our dashboard uses for table cards ships a browser build, so the tool is the same code path as the product feature. And the input handling is the thing I would most like people to copy:
function normalise(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
try { return new URL(candidate).toString() } catch { return null }
}
People type myvenue.co.uk. new URL('myvenue.co.uk') throws. The naive version of this tool shows them an error for typing the thing every human types. Assume https://, and only report a failure when it still will not parse.
Try it with a bare domain and watch it work.
What no backend buys you
An honest answer to "what do you do with what I type in here". Nothing. It never leaves the machine. That is not a privacy policy, it is an architectural fact, and you can verify it in thirty seconds: open DevTools, switch to the Network tab, generate a code, and watch nothing happen. A claim a user can check themselves is worth more than a paragraph in a policy they cannot.
No abuse surface. A server-side QR or PDF endpoint is a free, unauthenticated compute endpoint on your domain. Someone will find it. Then you need rate limiting, a size cap, a timeout, and probably a queue. All of that vanishes when the work happens on the visitor's own CPU, which is also a CPU you are not paying for.
No lifecycle. No generated file to store, serve, expire or leak by predictable URL.
What it costs, and the fix
The cost is bundle size. jspdf is large, and it sits behind one button on a marketing page that most visitors will never click. Loading it on every page view of the tools cluster would be paying for it on behalf of people who never use it.
Hence the dynamic import() inside the click handler, in both tools. The library is fetched the first time someone actually asks for a file, and the page's initial payload does not know it exists. This is the clearest case for code splitting I know of: a heavy dependency, a single interaction, an unpredictable and mostly negative hit rate.
The user-visible cost of that choice is a short delay on first click, which is covered by a busy state on the button.
The input clamp is not validation, it is a resource limit
const LIMITS = {
rounds: { min: 1, max: 12 },
questionsPerRound: { min: 1, max: 20 },
teams: { min: 1, max: 40 },
}
function clamp(value: number, { min, max }: { min: number; max: number }) {
if (!Number.isFinite(value)) return min
return Math.max(min, Math.min(max, Math.round(value)))
}
There is no server to protect here, so what is this for? The user's own tab. A typo that turns 6 rounds into 6000 is a 6000 page PDF, generated synchronously, on the main thread, on a phone. The tab dies and the person assumes the tool is broken.
Note the Number.isFinite branch. Number('') is 0, Number('abc') is NaN, and Math.max(1, Math.min(12, NaN)) is NaN, which then becomes a loop bound. Clamping without a non-finite guard is not clamping.
Clamping rather than rejecting is deliberate too. The value snaps into range as you type, so the boundary is discoverable without an error message.
Why any of this exists
These are marketing pages, in the honest sense: they are useful to someone who has never heard of us, they rank for things people search for, and they demonstrate the thing we sell without asking for an email address. A person who prints scoresheets from our tool this month is a person who might, next month, wonder whether the whole scoring part could be automatic.
That is what the rest of pub-trivia.app does, and the free tier needs no card if you want to compare the paper version to the live one.
Top comments (0)