I build Loretypes, a free site with six self-discovery quizzes: Aura Color, Archetype, Moral Alignment, Past Life, Color Season and Spirit Animal. They're for reflection and fun, not diagnosis. Past Life is openly fictional, and Color Season asks which color combinations you like rather than analyzing a photo.
The quizzes themselves are the easy part. The part that kept forcing design decisions was the humble result URL, /[cluster]/r/[shareId]. Someone takes a quiz, gets a result, posts the link in a group chat, and a friend opens it weeks later. In between, I may have rewritten the result copy, renamed a type, or changed how the payload is shaped. The link still has to work and still has to say the same thing.
This article walks backwards from that requirement.
1. The server decides the result
If a share link is going to mean anything, the result it points to can't come from the browser. Every quiz on the site renders through one QuizRunner component, and when you answer the last question it sends only your answers:
const response = await fetch(`/api/tests/${definition.slug}/results/`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ answers: finalAnswers }),
signal: controller.signal,
});
The route handler (app/api/tests/[slug]/results/route.ts) caps the body at 4 KB, rate-limits result creation, resolves the quiz definition by slug and hands off to processSubmission. Validation is strict and boring on purpose: answers must be an array of exactly questionCount integers inside the quiz's allowed range. The client never claims "I got Chaotic Good." The server computes it.
Scoring goes through a small strategy table in lib/scoring/engine.ts:
export function scoreAnswers(definition: TestDefinition, answers: number[]): ResultPayload {
const runner = strategyRegistry[definition.scoring.type];
if (!runner) {
throw new Error(`Scoring strategy is not implemented: ${definition.scoring.type}`);
}
return runner(definition, answers);
}
There are four implemented strategies: dimensional sum, bipolar dimensions, an alignment axis grid and a seasonal profile. The table is typed as a Partial record because one strategy name in the union, weighted-type, doesn't have a runner yet. Throwing is the right behavior there. A quiz that silently fell back to some other formula would produce results nobody could reproduce later.
The scoring itself is plain arithmetic. The alignment strategy centers each 1–5 answer on 3 and multiplies by the question's direction:
// Simplified: the question-kind check is omitted.
definition.questions.forEach((question, index) => {
const contribution = (answers[index] - 3) * question.direction;
if (question.axis === "lawChaos") lawChaos += contribution;
else goodEvil += contribution;
});
const cell = cellFromAxes(lawChaos, goodEvil);
The dimensional strategy is different: it sums per dimension, breaks ties by a configured order, and can return a "balanced" result when the spread is small (that's how the Aura quiz lands on White). Six quizzes share one endpoint, not one formula.
2. Store what was decided, not what was asked
Once a result is scored, saveResultWithRetry in lib/results.ts writes a record containing:
- a 12-character lowercase alphanumeric share id, generated with nanoid and a custom alphabet
- the test slug and the test version
- the scored payload
- a creation timestamp, plus an expiry field that new records set to
null
The raw answer array is not part of the record. Besides keeping answers out of storage, that's a stability choice: the link renders from the payload that was computed at the time, so a later change to scoring weights can't quietly change what an old link says.
If the generated id collides with an existing one, the save loop tries a fresh id, up to five attempts, and rethrows any other storage error. The store, the id factory and the clock are all injected, which turns out to matter a lot for testing.
3. A version field is useless without a read policy
Writing testVersion into every record takes one line. Deciding what happens when you read an old record after the quiz has changed is the actual work.
lib/tests/result-version.ts maps a stored record back to the definition it was scored against. For Archetype, that means an explicitly archived 0.2.0 definition that is kept around just so old results render with the copy and types they were created with:
// Explicit archive, never a fallback to whichever definition is current.
const archetypeVersions: Record<string, TestDefinition> = { "0.2.0": v020 };
Archetype also has a small read-only adapter that translates an older payload shape into the current field names at read time. The stored data is never rewritten.
The other quizzes don't all get the same treatment, and I think that's fine as long as it's deliberate:
| Policy | Quizzes | |---|---| | Explicit version maps (archived definitions) | Archetype, Alignment, Past Life, Color Season | | Exact match on current version only | Spirit Animal | | Falls through to the current definition | Aura |
The result page (app/[cluster]/r/[shareId]/page.tsx) validates the id format, loads the record, resolves the definition for that record's version and picks the matching result component. An unknown id or an incompatible version is a 404, not a best-effort render with the wrong copy.
4. Immutable records make immutable caching safe
Every result also has an Open Graph image at /[cluster]/og/[shareId].png, rendered on demand with ImageResponse from next/og. The route goes through the same steps as the page: validate the id, load the record, resolve the versioned definition, then choose a renderer based on the definition's OG template id.
A successful render is served with:
Cache-Control: public, max-age=31536000, immutable
That header is only defensible because of the decisions above. The store interface has create and find and nothing else, so a stored result can never change, and the image derived from it can't either. (MDN's Cache-Control reference covers what immutable promises to the browser.) A failed render returns a 500 with no-store, so a transient error doesn't get pinned in caches for a year. Nothing is written back to storage; on a cache miss the PNG is regenerated from the saved payload.
Posters are a separate, opt-in capability. Aura renders a 1200×630 OG image, a 1080×1080 square poster and a 1080×1920 story poster. Archetype and Alignment use one square layout for both poster routes. Past Life has no poster.
Share buttons build their URLs with a tiny helper that strips any query string or hash and adds a channel marker, so a link copied from a result page doesn't drag stray parameters along:
export function buildShareUrl(origin: string, pathname: string, channel: ShareChannel) {
const cleanPathname = pathname.split(/[?#]/, 1)[0];
const url = new URL(cleanPathname, origin);
url.searchParams.set("s", channel);
return url.toString();
}
5. Shareable, but not indexable
A result page needs a good preview when it's pasted somewhere, but thousands of near-identical result pages shouldn't compete with the actual content in search. Metadata is built centrally in lib/seo/metadata.ts, and result pages get a per-result image while opting out of the index:
return {
title,
description,
alternates: { canonical },
robots: { index: false, follow: true },
openGraph: { title, description, url: canonical, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
index: false emits the noindex rule Google documents for keeping a page out of search results, while follow: true still lets crawlers follow the page's links. The sitemap is built from indexableClusterPaths, which leaves results out entirely and filters Archetype type pages through a content-quality gate.
Everything that isn't a result is static. Landing pages, type pages and learn articles export generateStaticParams derived from a single quiz registry, with dynamicParams = false, so an unknown slug is a 404 rather than an on-demand render:
export function generateStaticParams() {
return testRegistry.map((definition) => ({ cluster: definition.cluster }));
}
export const dynamicParams = false;
The registry (lib/tests/registry.ts) is validated when the module loads, rejecting reserved route segments, duplicate slugs and colliding routes. Each definition carries two identifiers: a slug for the API and storage, and a cluster for URLs. They can differ. Moral Alignment is stored as moral-alignment but lives at /alignment/, and keeping the two apart means a URL can change without touching stored records.
6. Tests that pin the contract
The suite runs on Vitest: 72 files and 612 tests at the time of writing. It defaults to the Node environment, and files that need a DOM opt in with a // @vitest-environment jsdom comment (Vitest docs on environments). Most of the tests aren't about pixels. They're about the promises above:
- Registry tests construct broken definitions (reserved segments, duplicate slugs, colliding routes, inconsistent SEO) and assert that validation throws.
- Persistence tests inject a fake store that keeps throwing duplicate-id errors, to check both "succeeds on a later attempt" and "gives up after five."
- Server components rendered to strings. About a dozen files
awaitan async server component, pass the tree torenderToStaticMarkupand assert on headings, links, table markup and JSON-LD, with no browser involved. - Golden HTML fixtures. The homepage test renders the page and compares it byte for byte against a stored fixture. Learn-article tests strip the shared footer first, so the fixture only guards the content it's meant to protect.
The byte-for-byte fixture fails on any markup change, including a new footer link. That's the point. "Did this change anything else on the page?" becomes a diff in code review. When the change is intended, the fixture is regenerated in the same commit and the diff shows exactly what moved.
What I'd tell myself at the start
- Decide what a link promises before you build the share button. Everything else (where scoring runs, what gets stored, how long images are cached) follows from that one decision.
- Version fields need owners. For every quiz, write down whether old records get an archived definition, an exact-match check, or the current definition. "We'll figure it out when it breaks" means old links break.
- Make records append-only if you can. An append-only store is what makes a year-long immutable cache header safe instead of reckless.
- Keep URL names and storage names separate. A slug for data and a cluster for URLs costs one extra field and buys freedom to rename routes.
Disclosure: I built Loretypes, the site described in this article, and it is my own project. I used AI assistance to help draft and edit this article from my code and notes; the code, architecture and decisions described are my own. An earlier, shorter walkthrough of the same codebase appeared on dev.to.
