<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pea</title>
    <description>The latest articles on DEV Community by Pea (@pea_e93c29b3ea801ad).</description>
    <link>https://dev.to/pea_e93c29b3ea801ad</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4185723%2F3de2de3b-0b00-41da-b685-b718efcd62b0.jpg</url>
      <title>DEV Community: Pea</title>
      <link>https://dev.to/pea_e93c29b3ea801ad</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pea_e93c29b3ea801ad"/>
    <language>en</language>
    <item>
      <title>Building a 12-axis political quiz with Vite, TanStack Start and Cloudflare Workers</title>
      <dc:creator>Pea</dc:creator>
      <pubDate>Thu, 15 Oct 2026 01:59:53 +0000</pubDate>
      <link>https://dev.to/pea_e93c29b3ea801ad/building-a-12-axis-political-quiz-with-vite-tanstack-start-and-cloudflare-workers-22kp</link>
      <guid>https://dev.to/pea_e93c29b3ea801ad/building-a-12-axis-political-quiz-with-vite-tanstack-start-and-cloudflare-workers-22kp</guid>
      <description>&lt;p&gt;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 &lt;strong&gt;&lt;a href="https://12axes.site/" rel="noopener noreferrer"&gt;The 12 Axes&lt;/a&gt;&lt;/strong&gt;, a free quiz that scores you on twelve independent axes, in English, Spanish and Portuguese.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9sboopwfvdhzthe3k0c2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9sboopwfvdhzthe3k0c2.png" alt="The quiz screen: one statement at a time with seven labelled answers" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vite&lt;/strong&gt; for the build and dev server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Start&lt;/strong&gt; (with TanStack Router) for file-based routes, server rendering and typed search params.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers&lt;/strong&gt; to serve every page from the edge. The quiz itself has no backend at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no database. Everything a result needs fits in the URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring in a dozen lines
&lt;/h2&gt;

&lt;p&gt;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%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scoreQuiz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QuizItem&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sums&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;sums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A skipped axis stays at 50, the neutral middle, rather than breaking the result.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpu5sufxv2u7ggf6829yg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpu5sufxv2u7ggf6829yg.png" alt="Top of a result page: an archetype name, the closest ideology and the closest historical figure" width="800" height="542"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A result that fits in 24 characters
&lt;/h2&gt;

&lt;p&gt;Twelve scores from 0 to 100 need two base-36 digits each, so a whole result is a 24-character string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encodeScores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;scores&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string goes in the result URL as &lt;code&gt;?r=&lt;/code&gt;. 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 (&lt;code&gt;/^[0-9a-z]{24}$/&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matching ideologies and historical figures
&lt;/h2&gt;

&lt;p&gt;Two kinds of matches are shown, and both use root-mean-square distance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ideologies&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fovvsfpr7wftbdipoodip.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fovvsfpr7wftbdipoodip.png" alt="The closest ideology with the three nearest alternatives and their match percentages" width="800" height="542"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Historical figures&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2ceo8gnyoz2gfh7795o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2ceo8gnyoz2gfh7795o.png" alt="The five closest historical figures, followed by the twelve axis scores" width="800" height="542"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Progress that survives a reload
&lt;/h2&gt;

&lt;p&gt;The full test has 288 statements, so losing your place hurts. Progress lives in &lt;code&gt;localStorage&lt;/code&gt; and is restored on load. Answers never leave the browser, which is also why there is no sign-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  A share card made from the DOM
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canShare&lt;/span&gt;&lt;span class="p"&gt;?.({&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc49yimhg6tokuay9u9ir.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc49yimhg6tokuay9u9ir.png" alt="The share dialog with the 9:16 result card and platform buttons" width="800" height="542"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three languages, one set of routes
&lt;/h2&gt;

&lt;p&gt;English lives at &lt;code&gt;/&lt;/code&gt;, Spanish at &lt;code&gt;/es/&lt;/code&gt; and Portuguese at &lt;code&gt;/pt/&lt;/code&gt;. Every page renders &lt;code&gt;hreflang&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running on Cloudflare Workers
&lt;/h2&gt;

&lt;p&gt;The Worker's &lt;code&gt;fetch&lt;/code&gt; handler is a thin wrapper around the TanStack Start handler. It is also where redirects belong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the old launch domain 301s to the same path on the new one;&lt;/li&gt;
&lt;li&gt;plain &lt;code&gt;http://&lt;/code&gt; 301s to &lt;code&gt;https://&lt;/code&gt;, with a 308 for anything other than GET/HEAD so a request body is never silently turned into a GET.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Deploys are one &lt;code&gt;wrangler deploy&lt;/code&gt;. The custom domain, TLS and caching come with Workers.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO for a quiz site
&lt;/h2&gt;

&lt;p&gt;A few things mattered more than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server-render everything crawlable&lt;/strong&gt;, including FAQ answers inside collapsed panels: &lt;code&gt;hidden="until-found"&lt;/code&gt; keeps them in the HTML while staying collapsed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sitemap with real &lt;code&gt;lastmod&lt;/code&gt; dates&lt;/strong&gt; and an IndexNow ping after each content change, so Bing and other IndexNow engines hear about new pages without waiting for a crawl.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An explainer page per axis&lt;/strong&gt;, so the site answers "what does this axis mean" and not just "take the quiz".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The quick test takes 60 statements, about six minutes: &lt;strong&gt;&lt;a href="https://12axes.site/" rel="noopener noreferrer"&gt;12axes.site&lt;/a&gt;&lt;/strong&gt;. I'd love to hear which axis surprised you, and whether the historical-figure match felt fair.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>cloudflare</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
