The standard "generator" page is a heading, a button, and an empty box. Click the button, get a result. It is also, to a crawler, a page with about forty words on it, and those forty words do not include any of the content the page is ostensibly about.
We built one of these for pub quiz team names and deliberately inverted it. The page renders every name in the pool as ordinary server-rendered text, grouped and captioned. The interactive part picks six at random from that same pool. The tool is the convenience, not the content.
Turn JavaScript off and the page still answers the question you came with.
Where the data lives, and why it is not in the component
/**
* The team name pool behind /tools/team-name-generator.
*
* It lives in a module rather than inside the component so that the page can
* render every name into the HTML on the server. A generator whose entire
* content only exists after a button is clicked has nothing for a crawler to
* read, and "pub quiz team names" is a query people search far more often than
* they search for a generator. The interactive part picks from this list; it
* does not hold it.
*/
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', 'Agatha Quiztie', /* ... */],
},
// ...
] as const
One exported array, imported by a server component that renders it in full and by a client component that samples it. Same source, two presentations, no duplication and no API route.
Note the blurb on each category. A wall of names is a list; a list with a sentence explaining when to use each group is content. That sentence costs nothing to write and is the difference between a page that ranks and a page that is technically indexed.
The hydration bug this shape invites
Here is the trap, and it catches almost everyone who builds a random-thing component in a server-rendered framework:
// Wrong
export function TeamNameGenerator() {
const names = draw(pool, 6) // runs on the server AND on the client
return <ul>{names.map(/* ... */)}</ul>
}
The server picks six names and puts them in the HTML. The client picks six different names during hydration. React compares, finds a mismatch, and in modern versions discards the server HTML for that subtree and re-renders. You get a console error, a flash of replaced content, and in some framework versions a much louder failure.
Randomness is not a pure function of props, so it cannot happen during render. It happens after:
const [names, setNames] = useState<string[]>([])
const generate = useCallback(() => {
setNames(draw(pool, DRAW_SIZE))
}, [pool])
useEffect(() => {
generate()
}, [generate])
The first render produces an empty list on both sides, which matches. The effect runs only in the browser and fills it in. The same rule applies to Date.now(), crypto.randomUUID(), window.matchMedia and anything else the two environments will answer differently.
The useCallback is not decoration either: generate is in the effect's dependency array, and it closes over pool, so switching category re-runs the draw automatically. One dependency chain, no second effect watching the category.
Sampling without replacement, because duplicates look like bugs
function draw(pool: readonly string[], count: number): string[] {
const remaining = [...pool]
const picked: string[] = []
while (picked.length < count && remaining.length > 0) {
const index = Math.floor(Math.random() * remaining.length)
picked.push(remaining.splice(index, 1)[0])
}
return picked
}
Picking six independent random indices is one line shorter and produces "Quizteama Aguilera" twice in the same set of six often enough to notice. Users do not read that as randomness, they read it as a broken generator.
The copy-and-splice is O(n) per pick, which for a few hundred names is irrelevant, and it is obviously correct at a glance. The remaining.length > 0 guard handles a category smaller than the draw size, which is the kind of thing that only becomes true after somebody edits the data six months from now.
The editorial constraint is a technical one
From the same file:
Kept clean rather than crude on purpose. Whoever reads the results out has to say every one of these in front of a room that may include somebody's grandmother, and the clever ones get a bigger laugh than the shocking ones.
This is worth stating in the code, because the pool is the kind of file people add to casually. A generator with no moderation step inherits whatever is in its data, and the constraint is not "is this funny" but "would a quizmaster read it aloud". Writing that down next to the array is cheaper than a content policy nobody reads.
It is also the reason there is no LLM anywhere near this. The value is not novelty, it is a curated list that is reliably safe to read out, and no model gives you that guarantee at zero latency and zero cost per click.
What you get for the extra effort
The page ranks for the names themselves rather than for "team name generator", which is a much smaller and more competitive query. Someone searching a specific pun can land on it. And the crawler, the JavaScript-disabled visitor and the person on a terrible train connection all get the same page.
The rule generalises: if your interactive widget is the only place your content exists, you have hidden your content inside an interaction. Render the data, then let the widget be a nicer way to move through it.
Have a look
pub-trivia.app/tools/team-name-generator. Click through the categories, then view source and notice the whole pool is already there. If you run a quiz and would like the scoring to be as automatic as the naming, the free tier is on the homepage and does not ask for a card.
Top comments (0)