DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four tools on our marketing site, none of which have a backend, and one that competes with us

pub-trivia.app/tools has four free tools on it. Open your Network tab and use any of them. Nothing goes out. There is no API route behind them, no account, no email wall and no watermark, because there is nothing on the other end to send anything to.

That is not a privacy flourish, it is the architecture. Four tools with no backend cost us nothing to run and cannot be abused, which is the only reason it is reasonable to give them away with no conditions attached. It also makes "what do you do with what I type in here" answerable in one sentence.

Below are the parts that were not obvious when building them.

One of the tools is the thing our product replaces

We sell software that puts a pub quiz on everybody's phone and settles the scores as each question closes. One of our four free tools is a printable paper scoresheet.

That is deliberate. A venue running on paper this Tuesday is going to run on paper this Tuesday, whatever our landing page says. Being the site that gave them a decent scoresheet is a better position to be in than being the site that would not. The conversion, if it ever happens, happens in six months when marking forty sheets at the bar has stopped being charming.

It also means the tools are honest about what they are, which is a cheap way to be trusted on the pages where we are not being honest about anything, we are selling.

The tool is the convenience, the page is the content

A generator whose entire output only exists after a button press has nothing in it for a crawler to read. And "pub quiz team names" is searched far, far more often than "pub quiz team name generator" is.

So the pool of names lives in a module, not inside the component, and the page renders all 109 of them as ordinary text across five categories. The interactive part draws from that list, it does not hold it.

curl -s https://pub-trivia.app/tools/team-name-generator | grep -c "Quizteama Aguilera"
# 1
Enter fullscreen mode Exit fullscreen mode

Turn JavaScript off and the page still answers the question the visitor came with. The generator is then just a nicer way to read a list that was already there.

That shape generalises: on every one of these pages the server renders the explanation, the examples and the reasoning, and the client component renders the convenience. The convenience is the part that earns the link and the page is the part that earns the ranking.

The first random draw has to happen in an effect

This is the standard way a "random thing" component goes wrong in a server rendered app.

// Wrong: the server picks six names, the client picks six different ones,
// and React reports a hydration mismatch on every load.
const [names] = useState(() => draw(pool, 6))
Enter fullscreen mode Exit fullscreen mode

Choosing at render time means the two renders disagree, because nothing in the universe makes Math.random() return the same value twice. The fix is to render nothing on the first pass and draw in an effect, so the client is the only thing that ever chooses:

const [names, setNames] = useState<string[]>([])
useEffect(() => { generate() }, [generate])
Enter fullscreen mode Exit fullscreen mode

The draw itself is sampling without replacement, over a copy of the pool, so one click cannot hand you the same name twice. Three lines, and worth writing down because the obvious one-liner with sort(() => Math.random() - 0.5) is both biased and, on some engines, undefined behaviour.

Big dependencies are imported on the click, not on the page view

Two of the tools make PDFs, and jspdf is not a small library. A marketing page that loads it up front is paying for it on every single view, including the overwhelming majority where the visitor reads the page and never touches the button.

async function downloadSheet() {
  const { jsPDF } = await import("jspdf")
  // ...build the sheet
}
Enter fullscreen mode Exit fullscreen mode

Same for the QR library. It ships a browser build, so the exact dependency our dashboard uses for printing permanent table codes works here with no server involved, and it is only fetched by the people who actually generate a code.

The pattern is worth applying by the kilobyte rather than by taste: a dynamic import inside the handler is nearly free to write, and it moves the cost onto the interaction that needs it.

Clamp the inputs, because somebody will type 900

The scoresheet generator takes a number of rounds, questions per round and teams, then lays out a grid across A4.

const LIMITS = {
  rounds: { min: 1, max: 12 },
  questionsPerRound: { min: 1, max: 20 },
  teams: { min: 1, max: 40 },
}
Enter fullscreen mode Exit fullscreen mode

Those bounds are not product opinions about quiz nights, they are a guard against a typo asking the browser to build a nine hundred page PDF in the main thread. The failure mode of an unclamped number input is not an error message, it is a tab that stops responding, and the user has no idea they caused it.

The QR generator has the same category of problem at the other end of the keyboard. People type example.com, and new URL("example.com") throws. Rejecting that input is technically correct and practically useless, so a missing scheme is assumed to be https:// and anything that still will not parse is reported as a real mistake.

The running order calculator is the one with an opinion in it

The round planner is the one I would have thought least useful and is the one I would now keep if I had to drop three.

Everybody planning a first quiz night underestimates how long it takes, because they count the questions and forget everything around them: reading each one twice, waiting for the table that is arguing, giving the answers out. So each round type carries its own minutes per question and its own fixed overhead.

Round type Minutes per question Overhead
Standard 1.1 2 min
Quickfire or true/false 0.6 1.5 min
Music 1.3 3 min
Picture 0.8 4 min
List or connections 1.6 3 min

A music round is slower per question because clips get replayed and the room gets loud. A picture round is quicker to answer and slower to hand out, which is why its cost sits in the overhead instead. A connections round is the most expensive thing on the list because tables talk it through, which is the point of it and also the price of it.

Add five minutes of welcome, ten for results and prizes, and a break you position yourself, and the output is a running order with real clock times against it: a quiz that starts at 20:00 and finishes at 21:51, rather than six rounds of ten and a hopeful shrug.

The figures lean generous on purpose. A quiz that finishes early is one people come back to, and one that overruns by forty minutes is not.

The hub is generated, because four hand-written links is four chances to drift

The index at /tools is derived from the same content registry that produces the sitemap, the breadcrumbs and the footer, so a new tool is one registry entry plus one page, and the index, the ItemList structured data and the navigation all follow.

With four items a hand-written list would have been four lines of JSX. It would also have been a fifth place that has to be remembered, and the only question that matters about a list like that is what happens the day somebody forgets.

Have a go

  • pub-trivia.app/tools is the hub. Open the Network tab first and confirm nothing leaves your browser while you use any of them.
  • Team name generator: view source and find the names in the HTML, then turn JavaScript off and reload to see what a crawler sees.
  • Round planner: put in the quiz you were going to run and see what time it actually ends.
  • Scoresheet generator and QR code generator: both produce a print-ready A4 PDF, generated on your own machine.
  • If you would rather not mark forty sheets at the bar, that is what we sell. The first session is free and needs no card.

Top comments (1)

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

Did you measure whether the server-rendered list changed search traffic, or did the crawler test only confirm that the names were present in the HTML?