<?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: Alex Pavlov</title>
    <description>The latest articles on DEV Community by Alex Pavlov (@alexpavlov).</description>
    <link>https://dev.to/alexpavlov</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%2F4075265%2Fe0184d97-4a2a-4c3b-a0d1-e34c6fe0cca6.png</url>
      <title>DEV Community: Alex Pavlov</title>
      <link>https://dev.to/alexpavlov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexpavlov"/>
    <language>en</language>
    <item>
      <title>How I serve a 300 question personality test in 32 languages without storing a single answer</title>
      <dc:creator>Alex Pavlov</dc:creator>
      <pubDate>Wed, 12 Aug 2026 20:47:39 +0000</pubDate>
      <link>https://dev.to/alexpavlov/how-i-serve-a-300-question-personality-test-in-32-languages-without-storing-a-single-answer-15ck</link>
      <guid>https://dev.to/alexpavlov/how-i-serve-a-300-question-personality-test-in-32-languages-without-storing-a-single-answer-15ck</guid>
      <description>&lt;p&gt;The app is a personality test. Three hundred statements in the long form,&lt;br&gt;
one hundred and twenty in the short one, thirty facet scores at the end,&lt;br&gt;
thirty two languages. Next.js 14 on the App Router. Here are the three&lt;br&gt;
decisions that shaped everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The result is the URL
&lt;/h2&gt;

&lt;p&gt;The obvious design is: user answers, you write a row, you hand back an id.&lt;br&gt;
That means an account system in the way of the first click, a privacy&lt;br&gt;
policy conversation, and a database that grows with every visitor who was&lt;br&gt;
just curious.&lt;/p&gt;

&lt;p&gt;Instead the finished result is encoded into the link itself. Thirty facet&lt;br&gt;
sums, each a small integer, packed into a compact key of about forty six&lt;br&gt;
characters. The link is the result. Open it on another device and the page&lt;br&gt;
rebuilds the whole profile from it: five domain scores, thirty facets,&lt;br&gt;
percentile lookups, the written interpretation. No row is required for any&lt;br&gt;
of that.&lt;/p&gt;

&lt;p&gt;Storage becomes opt-in. A user who wants a history signs up and claims the&lt;br&gt;
result; everyone else costs nothing and leaves nothing behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Questions are not part of the page
&lt;/h2&gt;

&lt;p&gt;The landing pages are statically generated and are supposed to rank. The&lt;br&gt;
questionnaire items are neither: three hundred statements of public domain&lt;br&gt;
inventory text on an indexable page is a duplicate content problem and a&lt;br&gt;
gift to scrapers.&lt;/p&gt;

&lt;p&gt;So items live behind a static per-locale endpoint, fetched when the run&lt;br&gt;
actually starts, and the test screens carry noindex. The marketing pages&lt;br&gt;
stay static and fast, the item bank stays out of the index, and scoring is&lt;br&gt;
always done against the canonical English item order regardless of the&lt;br&gt;
language shown, so translations can never shift a score.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Thirty two locales, and the norms are not per locale
&lt;/h2&gt;

&lt;p&gt;Every visible string, every interpretation text, every long form article&lt;br&gt;
exists per language. The percentile tables do not: they are per age band&lt;br&gt;
and sex, from openly published research datasets, and they are the same&lt;br&gt;
tables no matter which language the interface is in. Keeping that boundary&lt;br&gt;
explicit stopped a whole class of bugs where someone "translates" a data&lt;br&gt;
file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually bit me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind and dynamic opacity.&lt;/strong&gt; Colour tokens were defined as full
colour values, so &lt;code&gt;bg-brand/10&lt;/code&gt; silently produced nothing. Tokens have
to be raw channel triplets for the slash-opacity syntax to compile.
Nothing errors; the colour is just missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dynamic route segment that swallowed root files.&lt;/strong&gt; Requests like
&lt;code&gt;/favicon.ico&lt;/code&gt; bypassed the locale middleware, matched the &lt;code&gt;[locale]&lt;/code&gt;
segment, and blew up inside metadata generation before the 404 branch
could run. A guard on the locale value fixed it. Worth checking on any
localised App Router site: request &lt;code&gt;/foo.txt&lt;/code&gt; on production and see
whether you get a real 404 or a 500.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long form content review does not scale by reading.&lt;/strong&gt; Verification
scripts that check structure, length bands, forbidden characters and
factual claims per file caught more real errors than manual review ever
did.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;The live app is at &lt;a href="https://ipipneo.com/en" rel="noopener noreferrer"&gt;ipipneo.com&lt;/a&gt;. The long version&lt;br&gt;
is &lt;a href="https://ipipneo.com/en/ipip-neo-300" rel="noopener noreferrer"&gt;here&lt;/a&gt; and the short one&lt;br&gt;
&lt;a href="https://ipipneo.com/en/ipip-neo-120" rel="noopener noreferrer"&gt;here&lt;/a&gt; if you want to see the flow,&lt;br&gt;
including what a share key looks like when the run finishes. Questions&lt;br&gt;
about any of the three decisions are welcome, especially the first one: I&lt;br&gt;
still meet people who think a result has to live in a table.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
