Try this. Open pub-trivia.app/tools/scoresheet-generator, open your Network tab, set the filter to Fetch/XHR, then ask it for six rounds of ten questions and a grid for twelve teams and press download. A PDF lands in your downloads folder and the Network panel stays empty.
There is no endpoint behind that button. The whole tool is a client component and a dependency, and the page it sits on is static.
We build quiz software for pubs. The tools cluster is four free utilities a quizmaster might want on a Tuesday: names for the teams, a printable answer sheet, a QR code, and a running order with real timings. They exist to rank for the things people search before they search for software. None of them needs a server, so none of them has one, and that constraint turned out to decide more of the design than the features did.
The page is the content, the tool is the convenience
The obvious way to build a team name generator is a button and a random pick. That version is worthless to a crawler: a page whose entire content appears after a click has nothing in the HTML to read, and "pub quiz team names" is searched far more often than "pub quiz team name generator" is.
So the pool lives in a module, not in the component:
export const TEAM_NAME_CATEGORIES = [
{
key: 'puns',
label: 'Puns on famous names',
blurb: 'The biggest category and the most reliable. A name everybody knows, bent around a quiz word.',
names: ['Quizteama Aguilera', 'Les Quizerables', 'Tequila Mockingbird', /* ... */],
},
// four more categories
] as const satisfies readonly TeamNameCategory[]
export const ALL_TEAM_NAMES: readonly string[] = TEAM_NAME_CATEGORIES.flatMap(
(category) => category.names
)
The page renders all 110 of them as ordinary text on the server, grouped under the five category headings. The interactive widget sits above that and picks from the same array. You can check that the list is really in the document rather than painted in afterwards:
curl -s https://pub-trivia.app/tools/team-name-generator | grep -c "Quizteama Aguilera"
That returns 1, from the server-rendered list, because the widget renders nothing at all on the server. Which brings up the bug.
The first draw has to happen in an effect
A component that picks something at random during render is a component that picks a different thing on the server than it picks in the browser, and React calls that a hydration mismatch. It is the standard way a "random thing" widget goes wrong, and it is easy to miss because the page still looks right.
const generate = useCallback(() => {
setNames(draw(pool, DRAW_SIZE))
}, [pool])
useEffect(() => {
generate()
}, [generate])
Initial state is an empty array, which is what the server renders, and the first draw happens after mount. The empty state is not a spinner either, it is a line of text that says what the button does, because for the one frame it exists a skeleton loader would be a lie about work being done.
draw splices out of a copy of the pool rather than picking six indexes independently, so a draw of six is six different names. Sampling with replacement and hoping is fine until somebody sees the same name twice in a list of six and reports it as a bug, which at a pool of 110 and six draws happens more often than the intuition says.
jspdf is imported by the button, not by the page
Two of the four tools produce a PDF. jspdf is not a small dependency, and the tools pages are marketing pages that a lot of visitors will read and leave.
const download = async () => {
setBusy(true)
try {
const { jsPDF } = await import('jspdf')
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
// ...
A dynamic import() inside the handler means the cost is paid by the people who press the button and by nobody else. The same applies to qrcode on the QR tool. Both of those packages ship a browser build and map the entry point for bundlers, so the dependency the dashboard already uses to make permanent table codes works unchanged here with no server involved.
That is a bundle-size argument, and it is the less interesting half. The better one is that the URL a visitor types into the QR tool never leaves their machine. "What do you do with what I put in here" has a much stronger answer when the answer is "there is nowhere for it to go", and it is an answer the reader can verify in ten seconds rather than take on trust.
Clamp at the input, because the loop trusts the number
The scoresheet generator's PDF is built by nested loops over the config: a page per round, a numbered line per question, then a landscape grid with a row per team.
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)))
}
Clamping happens in the setter, not at download time, so the number in the input is always the number the PDF will use. A max attribute on a number input is advisory: it styles the field as invalid and stops the spinner going higher, and it does not stop anyone typing 9000 or pasting it. Without the clamp that is a 9000-page PDF generated on the main thread, which is not a security problem on a page with no backend, but it is a locked-up tab and a bad first impression.
The Number.isFinite branch is there for the empty string. Clearing the field gives you Number(''), which is 0, and Number('abc'), which is NaN, and Math.max(1, Math.min(12, NaN)) is NaN, which becomes a loop that never runs. Falling back to the minimum means an empty field behaves like one round rather than like no document.
The row and line spacing are both computed from the count rather than fixed:
const lineGap = Math.min(
12,
(A4_HEIGHT_MM - MARGIN_MM - y - 10) / config.questionsPerRound
)
Twenty questions at a fixed 12mm gap runs off the bottom of an A4 page. Taking the smaller of the comfortable gap and the gap that fits means ten questions are generously spaced and twenty are tight, instead of twenty being silently truncated.
A bare domain is not a parse error
The QR tool takes a URL. People type example.com/quiz, and new URL('example.com/quiz') throws.
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
}
}
Assume https:// rather than rejecting the input. The scheme is the part of a URL that nobody says out loud and nobody types, and a validator that demands it is a validator that is technically correct at a cost paid entirely by the user. What is left after that is a genuine mistake worth an error message.
The error message is "That does not look like a web address", not "Invalid URL". The tool has one input and one job, so there is no ambiguity to resolve with jargon.
We ship the thing our product replaces
The scoresheet generator prints paper answer sheets. Our product exists so that nobody has to mark paper answer sheets.
That is deliberate, and the tools hub says so in as many words. A venue running on paper this Tuesday is going to run on paper this Tuesday, whatever is on our pricing page. Being the site that gave them a decent scoresheet is a better position to be in than being the site that would not, and the page that gets the visit is the one that solved the problem they actually had today.
The hub index is generated from the content registry rather than hand-written, which at four tools is almost not worth doing:
const TOOLS = spokesOf('tools')
Almost. Four hand-written list items is four lines that can fall out of step with the registry, and the registry is what the sitemap, the breadcrumbs and the footer are built from. A hub whose list disagrees with the sitemap is the kind of thing nobody notices for a quarter.
See for yourself
All four are at pub-trivia.app/tools, no account and no email address:
- Team name generator, and view source to find the full list of 110 already in the HTML.
- Scoresheet generator. Ask for twelve rounds of twenty questions for forty teams and watch it stay responsive.
- QR code generator. Type a bare domain with no scheme and watch it get a scheme put on it for you.
- Round planner, which is the one where the arithmetic is the whole product: everyone planning a first quiz night counts the questions and forgets that reading each one twice and settling an argument at table four costs more than the answering does.
The offline check is the one I would actually run, with one honest caveat about the order. Press each button once with the network up, which pulls in the jspdf and qrcode chunks, then set the DevTools throttling dropdown to Offline and use all four again. Everything still works, because there was never a round trip to lose.
Go offline before the first press and the PDF buttons fail, because the dynamic import() is itself a request. That is the trade the dynamic import makes: the dependency is free for everyone who never presses the button, and it is one chunk fetch for everyone who does. It is the only request any of these pages makes after the document, and the Network tab will show you that it goes to our own static assets and not to an API.
If you want to see what the paid version does with the same problem, the free tier needs no card.
Top comments (0)