We built four free tools for a pub quiz site. One of them picks team names out of a pool. The obvious shape for that is a client component holding an array, a button, and six results.
We shipped a different shape, and the reason is a search query.
"pub quiz team names" is searched far more often than "pub quiz team name generator". People do not want a generator. They want a list they can scroll on their phone while the first round is being read out. And a page whose entire content only exists after a button press has nothing on it for a crawler to read, nothing for a visitor who arrived wanting to scroll, and nothing to rank with.
So the pool does not live in the component.
Move the data out, render it as text, keep the button
// lib/tools/team-names.ts
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
It is a module, so the server component can import it and put every name into the markup:
{TEAM_NAME_CATEGORIES.map((category) => (
<div key={category.key}>
<h3>{category.label}</h3>
<p>{category.blurb}</p>
<ul>
{category.names.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</div>
))}
The client component imports the same module and picks six. It holds nothing. The tool is the convenience, the list is the page.
You can check that the split is real rather than a claim:
curl -s https://pub-trivia.app/tools/team-name-generator | grep -c "Tequila Mockingbird"
That returns hits with no JavaScript executed at all, and the same is true of all 109 names. Turn JavaScript off in devtools and reload the page: the button stops working and the page is still the thing people came for.
The bug this shape walks straight into
A "pick something at random" component has one classic failure, and it is worth naming because the fix is not where you expect.
// Wrong, and it looks right
export function TeamNameGenerator() {
const [names, setNames] = useState(draw(ALL_TEAM_NAMES, 6))
// ...
}
useState runs its initialiser during render, and in an app with a server render that means it runs twice: once on the server, once on the client, with a different six each time. React hydrates, finds markup it did not predict, and reports a hydration mismatch. Nothing visibly breaks, which is the worst version of this.
The first draw has to happen after the component is mounted, not while it is rendering:
const [names, setNames] = useState<string[]>([])
const generate = useCallback(() => {
setNames(draw(pool, DRAW_SIZE))
}, [pool])
useEffect(() => {
generate()
}, [generate])
The component now renders nothing on its first pass, which would be a problem on a page whose content it owned. It is not a problem here, because it does not own the content. The page under it is already full. That is a second reason to put the data in a module rather than in the component: it makes the empty first frame harmless.
The other three tools, and a claim that has to be verifiable
The same page cluster has a QR code generator, a printable scoresheet builder and a round planner. All three run entirely in the browser, and the page says so:
No account, no email address, no watermark and no limit on how many times you use them. Every one of these runs entirely in your browser, and what you type into them is never sent to us, because there is nothing here to send it to.
That last clause is doing the work. It is not a privacy policy promise, it is an architectural fact, and a reader can check it in about fifteen seconds: open the QR generator, open the Network tab, type a URL, generate. Nothing leaves. The qrcode package ships a browser build, so the same dependency our dashboard uses for table codes works here with no server involved.
The scoresheet tool is the one I like most, because it is a printable paper answer sheet, published by the company whose product exists to replace paper answer sheets. A venue running on paper this Tuesday is going to run on paper this Tuesday. Being the site that helped is worth more than being the site that refused.
Both PDF tools import jspdf dynamically:
const { jsPDF } = await import('jspdf')
It is a large dependency behind one button on a marketing page that most visitors will read and never click. A static import makes every page view in the cluster pay for it.
What this actually buys
Four tool pages that each rank for the thing the tool does, instead of four pages that rank for nothing because their content is held in useState. The pattern generalises to anything with a finite pool behind it: name generators, converters, cheat sheets, lookup tables, pickers.
The test is simple. curl the page and read what comes back. If the answer is an empty <div> and a button, the crawler sees an empty <div> and a button, and so does every visitor for the few hundred milliseconds before hydration.
The four tools are at pub-trivia.app/tools if you want to curl them yourself. Our actual product, the one that puts the team name on a leaderboard instead of a printed sheet, is at pub-trivia.app, and the first session is free with no card.
Top comments (0)