◆ ENGINEERING

How we built JobCannon: 130+ assessments, sacred-flow architecture, 100% Codex-reviewed money flows

JobCannon is a B2C self-discovery platform — career, personality, and skills assessments grounded in real psychometric research, with a paywalled deep-dive layer on top of free results. As of this week, it's running 130+ assessments, ~2,500 careers in the graph, ~1,500 skills, and a quiet bit of architecture I think is worth writing down. This isn't a marketing post. It's an engineering post.

The point is not to brag. The point is: if you're building something with a lot of branching content, a non-trivial paywall, and a small team — there's a shape that works, and it's not the shape most CMS-driven products take.

The premise: tests are not pages, they're functions

Most "assessment" sites treat each test as a CMS page with hardcoded JSX, prose, scoring logic copy-pasted between files. That works for two tests. It collapses at twenty. By thirty, you're rewriting the same bug four times.

Our reframe: a test is a configuration object that produces (a) a question stream, (b) a scoring function, (c) a result mode. Everything else — the intro page, the funnel, the visualisation, the premium content — is a renderer that takes those three things and draws something. So the system has six small config files, and adding a new test is a 7-line PR.

// lib/scoring/config.ts
export const CONFIG = {
  big_five: { mode: 'bigfive', q: 50, scoring: 'OCEAN' },
  mbti:     { mode: 'mbti',    q: 70, scoring: 'fourPoles' },
  // ...
}

// lib/test-meta.ts → icons, gradients, color pairs
// lib/assessment-intro-config.ts → duration, question count
// lib/result-funnel-config.ts → which blocks show anon/free/premium
// lib/question-types-config.ts → grid-2col, spectrum-5, multi-card
// lib/admin/projects-tracker.ts → who's working on what

One source of truth per concern. The render layer never invents data; it dispatches by mode.

The dispatch table that ate the cards

Result pages used to be 2,400 lines of conditional JSX — "if MBTI render this, if Enneagram render that." We collapsed it to a single dispatcher:

// components/result/visuals/ResultCardDispatch.tsx
const cards = {
  mbti:        dynamic(() => import('./MBTIResultCard')),
  bigfive:     dynamic(() => import('./BigFiveResultCard')),
  enneagram:   dynamic(() => import('./EnneagramResultCard')),
  disc:        dynamic(() => import('./DISCResultCard')),
  riasec:      dynamic(() => import('./RIASECResultCard')),
  // ...
  generic:     dynamic(() => import('./GenericResultCard')),
}

export function ResultCardDispatch({ mode, ...props }) {
  const Card = cards[mode] || cards.generic
  return <Card {...props} />
}

Two effects fall out of this for free. First, only the active mode's chunk ships to the browser — a free user taking the MBTI test never downloads the Enneagram visualisation code. That's hundreds of kilobytes of saved bandwidth on every load. Second, when someone reports a bug in the DISC visual, the blast radius is one file. Not nine.

"Sacred flows" — the contract we never break

Internally we have a phrase: sacred flows. There are three: the test entry from landing, the question stream itself, and the paywall conversion on the result page. If any of those break for fifteen minutes, the business is dead. So they have rules everything else doesn't:

  • Sacred flows ship via staging branch only — never directly to main.
  • Every PR that touches them gets adversarial review (we use GPT-5 / Codex for this — more on it below).
  • End-to-end Playwright walks the flow on every preview.
  • The handler refs that wire CTA → flow logic live at component-body top level. Never nested in a phase-conditional. We learned that one the hard way: a CTA was dead for 24 hours because a ref assignment was buried inside an unreachable phase branch.

Treating these flows as different from "regular features" was the most important decision in the codebase. It cost us velocity on those exact features. It saved us from every category of catastrophic regression at least once.

The paywall: two options, no third path

Paywalls are where products go to die. The temptation is to add fallbacks: "or enter your email for a free PDF." Don't. Every fallback is an off-ramp from revenue.

JobCannon's paywall is two options: $19.99 single deep-dive, or $9.99/month all-access subscription. That's it. No "free email version." The free result page already gives genuine value — the basic profile, the visualisation, the top three career matches. The paywall buys depth: 30+ pages, career-DNA cluster analysis, growth plan, downloadable PDF.

It's inline at 50–60% scroll. It's not a sticky bottom bar (those train banner blindness). It's not a popup. It's a section, in the natural flow, with a scroll-to anchor. Conversion is in the high single digits on raw traffic, double-digit on logged-in users who've taken three or more tests.

The Stripe webhook trap that costs people money

Real story from this codebase. We had two Stripe webhook endpoints — one for staging (sk_test), one for prod (sk_live). At one point, a stale endpoint configuration on the live account was still pointing at the staging URL. So live charge.succeeded events were being processed by the test environment, which (of course) couldn't validate them, fired a silent failure, and the user's premium unlock never landed in production.

From the user side: paid, no premium. From our side: no obvious error. Stripe dashboard showed the charge as completed. Our app DB showed no premium grant. Two days of confusion until we ran webhookEndpoints.list() and saw the misrouted URL.

This is now a class-wide rule: any "notification didn't arrive" debugging starts with listing webhook endpoints first. Saves hours.

Codex-reviewed money flows — the second-opinion budget

I made a rule six months in: every PR that touches Stripe, premium gates, payment flows, migrations on monetisation tables, or analytics tracking gets an automated adversarial review by GPT-5 (Codex) before merge. Not advisory. Mandatory. The CI gate fails if it isn't run.

The cost is roughly $0.50 per PR. The benefit is real — Codex has caught at least four genuine bugs that would have shipped: a webhook signature verification that was string-comparing instead of constant-time, a race condition between subscription cancellation and the next billing cycle, a feature flag that gated the wrong premium block, and an analytics event that double-fired on retry.

You don't need GPT-5 specifically; you need a second pair of eyes that doesn't get tired. For money flows, that's worth more than another design review.

Slug formats — the silent bug factory

One of those "feels small, isn't" decisions: every test has a URL slug (big-five) and a DB type (big_five). Three of those silent .replace(/-/g, '_') calls scattered through the codebase, and you get an invisible bug class where two of them disagree and the result silently doesn't load.

Fix: a tiny lib/slug-utils.ts with toSnake, toDashed, normalizeSlug. Every consumer of slugs goes through one of those three functions. The string transforms are banned in CI (a regex check). It's the kind of guard you don't think you need until the second time you've debugged a 404 on a URL that exists.

Validators are non-negotiable

Every "standard" we adopted got a validator. Not a memory note, not a Notion doc — a script that runs in CI:

  • npm run validate:popups — every exit-intent popup uses brand gradient + correct copy.
  • npm run validate:paywall — exactly two paywall options, never three.
  • npm run validate:tests-matrix — every test has all 4 columns green (intro, funnel, scoring, types).
  • scripts/verify-jsonld-dedupe.mjs — no duplicate JSON-LD blobs in the rendered HTML.

If a standard isn't validated, it isn't a standard. It's a hope.

Lessons that don't fit anywhere

  1. Pick the boring stack. Next.js 16, Vercel, Supabase, Stripe. We don't have a single piece of infra that requires a specialist on payroll.
  2. Lazy-load aggressively. The home page never downloads the premium-content components. The intro page never downloads the result visualisations. Bundles stay under 200KB main route.
  3. Animate transform and opacity only. Anything else triggers layout, and on the question stream you'll see frame drops on mid-tier Android.
  4. Never invent numbers in UI copy. "40+ pages of analysis" was a lie because we generate three. Marketing claims have to grep against the generator.
  5. Project tracker as ground truth. One tracker file in the repo, one admin page rendering it. No Trello, no Asana, no Linear. Every PR updates the tracker.

Where we're going

The next architectural piece is the premium engine — moving premium content from per-test prose files into a templated system: engine/ + data/<test>.content.ts. The shape is "template renders sections × data feeds the slots." Once that's in, adding a new premium test becomes a 200-line data file instead of a 1,500-line prose file. We're halfway there.

That's roughly the architecture. Two more years of iteration and probably half of this will be wrong. But these are the bones, and they've held under real load and real product changes.

Need this kind of architecture for your product?

This is the work we do at MIR · Development. AI/SaaS products, fintech, recruitment-tech — built like JobCannon, not like a brochure site.

Start a project →