Most political quizzes squeeze you onto one line, left vs right, or a two-axis compass. I wanted something that keeps questions apart: you can want a strong welfare state and still distrust central government, or love new technology and be socially traditional. So I built The 12 Axes, a free quiz that scores you on twelve independent axes, in English, Spanish and Portuguese.
The statements and the scoring model come from the open-source 12Axes quiz (MIT). My part was the product around it: a fast, server-rendered site with shareable results and no accounts. Here is how it fits together.
The stack
- Vite for the build and dev server.
- TanStack Start (with TanStack Router) for file-based routes, server rendering and typed search params.
- Cloudflare Workers to serve every page from the edge. The quiz itself has no backend at all.
There is no database. Everything a result needs fits in the URL.
Scoring in a dozen lines
Each statement belongs to one axis and points toward one of its two poles. Answers run from -2 (strongly disagree) to +2 (strongly agree). An axis score is the average answer, flipped for statements that point the other way and mapped to 0-100%:
export function scoreQuiz(items: QuizItem[], answers: (number | undefined)[]) {
const sums = new Array(12).fill(0);
const counts = new Array(12).fill(0);
items.forEach((item, i) => {
const answer = answers[i];
if (answer === undefined) return;
sums[item.axis] += answer * (item.level < 4 ? 1 : -1);
counts[item.axis] += 1;
});
return sums.map((sum, axis) =>
counts[axis] ? Math.round(50 + (50 * sum) / (2 * counts[axis])) : 50
);
}
A skipped axis stays at 50, the neutral middle, rather than breaking the result.
A result that fits in 24 characters
Twelve scores from 0 to 100 need two base-36 digits each, so a whole result is a 24-character string:
export const encodeScores = (scores: number[]) =>
scores
.map((s) => Math.min(100, Math.max(0, Math.round(s))).toString(36).padStart(2, '0'))
.join('');
That string goes in the result URL as ?r=. Sharing a result is sharing a link: the result page rebuilds every score, match and chart from those 24 characters, so nothing is stored anywhere. Decoding validates strictly (/^[0-9a-z]{24}$/, every value ≤ 100) and anything else falls back to an empty state. TanStack Router’s typed search params make this pleasant: the route declares the param, and the page component gets a typed value.
Matching ideologies and historical figures
Two kinds of matches are shown, and both use root-mean-square distance:
- Ideologies are defined on four classic dimensions (economy, power, diplomacy, culture), so they are compared on the four matching axes. Match = 100 minus the RMS gap.
- Historical figures have a position on up to twelve axes. Many took no stand on some of them; Aristotle had no view on biotechnology. Skipping those axes would favour figures with few known positions, because they get compared on less. So an unknown axis counts as a fixed gap of 28 points, and a figure needs at least six known axes to be ranked at all.
One bug worth sharing: I had entered one axis backwards for twenty figures, and every match still looked plausible. Now a unit test pins the direction of every axis with figures whose positions are unambiguous. Plausible-looking output is the hardest kind of wrong to notice.
Progress that survives a reload
The full test has 288 statements, so losing your place hurts. Progress lives in localStorage and is restored on load. Answers never leave the browser, which is also why there is no sign-up.
A share card made from the DOM
The share card is a normal React component styled like the rest of the site. To share it as an image, it is rendered off-screen and turned into a PNG in the browser. On phones that support the Web Share API with files, the share sheet gets the image directly:
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], url, title });
}
Elsewhere you get a download button. Building the card as a component, not a canvas drawing, means it uses the same fonts, translations and layout code as the page.
Three languages, one set of routes
English lives at /, Spanish at /es/ and Portuguese at /pt/. Every page renders hreflang alternates and a canonical URL. Each language also gets its own Open Graph image, so a link shared in a Spanish group chat shows a Spanish preview.
Running on Cloudflare Workers
The Worker’s fetch handler is a thin wrapper around the TanStack Start handler. It is also where redirects belong:
- the old launch domain 301s to the same path on the new one;
- plain
http://301s tohttps://, with a 308 for anything other than GET/HEAD so a request body is never silently turned into a GET.
Both live in a pure function with unit tests, including one that feeds each redirect target back in and expects no further redirect. A redirect loop on the edge is not something you want to discover from Search Console.
Deploys are one wrangler deploy. The custom domain, TLS and caching come with Workers.
SEO for a quiz site
A few things mattered more than I expected:
-
Server-render everything crawlable, including FAQ answers inside collapsed panels:
hidden="until-found"keeps them in the HTML while staying collapsed. -
A sitemap with real
lastmoddates and an IndexNow ping after each content change, so Bing and other IndexNow engines hear about new pages without waiting for a crawl. - An explainer page per axis, so the site answers “what does this axis mean” and not just “take the quiz”.
Try it
The quick test takes 60 statements, about six minutes: 12axes.site. I’d love to hear which axis surprised you, and whether the historical-figure match felt fair.
