The app is a personality test. Three hundred statements in the long form,
one hundred and twenty in the short one, thirty facet scores at the end,
thirty two languages. Next.js 14 on the App Router. Here are the three
decisions that shaped everything else.
1. The result is the URL
The obvious design is: user answers, you write a row, you hand back an id.
That means an account system in the way of the first click, a privacy
policy conversation, and a database that grows with every visitor who was
just curious.
Instead the finished result is encoded into the link itself. Thirty facet
sums, each a small integer, packed into a compact key of about forty six
characters. The link is the result. Open it on another device and the page
rebuilds the whole profile from it: five domain scores, thirty facets,
percentile lookups, the written interpretation. No row is required for any
of that.
Storage becomes opt-in. A user who wants a history signs up and claims the
result; everyone else costs nothing and leaves nothing behind.
2. Questions are not part of the page
The landing pages are statically generated and are supposed to rank. The
questionnaire items are neither: three hundred statements of public domain
inventory text on an indexable page is a duplicate content problem and a
gift to scrapers.
So items live behind a static per-locale endpoint, fetched when the run
actually starts, and the test screens carry noindex. The marketing pages
stay static and fast, the item bank stays out of the index, and scoring is
always done against the canonical English item order regardless of the
language shown, so translations can never shift a score.
3. Thirty two locales, and the norms are not per locale
Every visible string, every interpretation text, every long form article
exists per language. The percentile tables do not: they are per age band
and sex, from openly published research datasets, and they are the same
tables no matter which language the interface is in. Keeping that boundary
explicit stopped a whole class of bugs where someone "translates" a data
file.
What actually bit me
-
Tailwind and dynamic opacity. Colour tokens were defined as full
colour values, so
bg-brand/10silently produced nothing. Tokens have to be raw channel triplets for the slash-opacity syntax to compile. Nothing errors; the colour is just missing. -
A dynamic route segment that swallowed root files. Requests like
/favicon.icobypassed the locale middleware, matched the[locale]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/foo.txton production and see whether you get a real 404 or a 500. - Long form content review does not scale by reading. Verification scripts that check structure, length bands, forbidden characters and factual claims per file caught more real errors than manual review ever did.
Result
The live app is at ipipneo.com. The long version
is here and the short one
here if you want to see the flow,
including what a share key looks like when the run finishes. Questions
about any of the three decisions are welcome, especially the first one: I
still meet people who think a result has to live in a table.
Top comments (0)