Build a quiz or recommendation flow
I want visitors to answer questions and get a personalized recommendation.
Author a scored flow in the dashboard, publish a public link, and render it on your site. Branching and scoring are server-side; the result bands are your recommendations.
Typical effort: 20 minutes, no engine code
The recipe — paste this to your AI
# Recipe — build a quiz, survey, or recommendation flow on your public site
Your site ships the Genesis **questions engine** as a public renderer: an operator authors a
flow (questions, branching, scoring) in the dashboard at **Questions**, publishes a public
link, and any page on this site can render it. Branching and scoring are **server-side** — the
rules never reach the browser — and answers land in your organization as structured responses.
This is how you build a "**which package is right for me?**" recommendation quiz, an intake
survey, a lead qualifier, or a scored assessment — with **zero engine code**.
> Building a lead qualifier or intake? Read the companion
> `capture-leads-and-crm-facts-from-forms.md` too — it covers where the answers GO: form
> fills becoming CRM leads, and library-keyed questions becoming queryable profile facts
> your follow-up (and next year's flow) can use.
## What ships already (don't rebuild these)
| Piece | Where |
|---|---|
| Public fill page (anyone with the link) | `/form/<publicLinkKey>` |
| Public survey/embed page | `/q/<slug>`, tokened invites at `/q/invite/<token>` |
| The typed client | `flowPublicApi` in `lib/api/flow.ts` — `resolve` / `step` / `submit` |
| The player component | `components/flow/flow-player.tsx` (renders any flow) |
| The result view | `components/flow/flow-result.tsx` (score, band label, thank-you) |
| Booking hand-off inside a flow | `components/flow/flow-slot-picker.tsx` |
| One flow continuing in another ("they said wedding → the wedding questions") | a **continue in flow** rule on the opening's question (an edge with `targetAction: 'continue_in_flow'` and `flowSlugContinue`, saved through the flow's edges API — the dashboard builder has no control for it yet); `SiteFlowEmbed` follows it in place, carries the answers, and tells you through `onHandOff(slug)` |
**Branching by kind is a hand-off, not a site-side `goTo`.** Keep one shared opening flow and
one flow per kind; put a *continue in flow* rule with a condition (`event_kind` equals
`wedding`) on the opening's question. The embed records the opening, then plays the wedding
flow seeded with what was already said — so nothing is asked twice and every answer lands as a
response. A hand-rolled router in a page component is the second engine this recipe exists to
prevent.
The scoring contract (from `lib/api/flow.ts`) is what makes recommendations work:
```ts
interface FlowSubmitResult {
totalScore: number;
normalizedScore?: number | null; // 0–1 fraction of best achievable (quiz mode)
scoreLabel?: string | null; // the operator-named band, e.g. "Growth plan fits you"
showResultPage?: boolean;
}
```
The operator defines **score bands** (`minScore` → `label`) in the dashboard — the band label
IS the recommendation. Change the bands or questions in the dashboard and the live site
updates; no redeploy.
## Step 1 — author the flow (dashboard, ~10 minutes, no code)
1. Dashboard → **Questions** → create a flow. Add your questions; reuse library questions
where offered (they carry canonical keys, so answers become durable profile facts).
2. For a recommendation quiz: give answer options **point values**, then define **result
bands** — each band's label is what the visitor will be told ("You need the Starter kit" /
"Talk to us about the Pro plan"). Enable the result page.
3. **Publish** and copy the **public link key** (the token in the `/form/<key>` URL).
### Asking for PHOTOS — for trades that cannot quote without seeing the property
Some businesses genuinely cannot give a number from a questionnaire. A lighting or lawn
company needs to see the roofline, the yard, the access; an events company never does. So
this is a **per-site choice, and it needs no code either way**: add a **`file_upload`
question** to the flow and the visitor gets a picker; leave it out and nothing changes.
Put it **last**, after the recommendation, and say why you are asking — a photo request
in front of the value is a wall, and behind it is a small favour:
> **A few photos, so your quote is real**
> To price this properly we need to see what we would be working on — the front of the
> house, any side you want included, and a wider shot showing access. Rough phone
> pictures are perfect; we are looking at shape and access, not photography.
Make it **optional** unless you truly cannot proceed without it. A required photo step at
the end of an otherwise finished flow throws away the answers of everyone who is not
standing at home.
What the platform does for you, with no wiring on your side:
- The visitor needs **no account**. The public-link door mints a presigned upload on its
own key authority.
- The file lands in **your** Drive, under **your** org, billed to **your** quota — not the
visitor's — so it is attached to the answers when you open the response.
- Size is capped by the file spine's own ceiling (25 MB per file, surfaced as a friendly
error), and a flow with no file question can never be used as an anonymous blob drop.
One caveat worth knowing before you promise it on a marketing page: uploads need the
**file storage backend configured for the deployment**. If it is not, the picker reports
the failure rather than silently dropping bytes — but check it end-to-end on your own site
before you write "send us photos" into your copy.
## Step 2 — put it on your site (a lane page — upgrade-safe)
Option A — just link it: add the `/form/<key>` URL to `site.config.ts → marketing.nav` or any
CTA. Done.
Option B — a branded page around it (recommended for a recommendation quiz). The canonical
wiring lives in `app/form/[publicLinkKey]/page.tsx` — **copy that file into your own segment**
and brand around it. The load-bearing shape (what the framework page does for you):
```tsx
// app/(marketing)/recommend/page.tsx — YOURS (a new segment = a lane).
// Start by copying app/form/[publicLinkKey]/page.tsx, hardcode YOUR key, then
// wrap it in your hero/branding. The wiring it gives you:
// 1. flowPublicApi.resolve(key) → the flow definition to render
// 2. <FlowPlayer flow={flow}
// step={(answers) => flowPublicApi.step(key, answers)} // server-side branching
// onSubmit={(answers) => submitMutation.mutate(answers)} /> // → FlowSubmitResult
// 3. on result: render <FlowResult …> (score + band label), then YOUR
// band→CTA mapping (deep-link each scoreLabel to a service or booking page).
```
`FlowPlayer` takes a **resolved flow** plus `step`/`onSubmit` callbacks — it does not fetch by
key itself; the resolve/step/submit calls stay in your page, exactly as the framework form page
does them. Then make your page reachable and findable in `site.config.ts`:
```ts
marketing: {
nav: [ /* … */, { label: 'Find your fit', href: '/recommend' } ],
publicPaths: ['/recommend'], // reachable without login
sitemapPaths: ['/recommend'], // indexed by search engines
},
```
## Step 3 — route the result somewhere useful
On submit you get `scoreLabel`. Map bands to next steps on YOUR page — deep-link each
recommendation into the matching service, package, or booking page. The mapping lives in your
lane file, so marketing can retune bands in the dashboard without touching code.
## Rules that keep it correct
- The flow definition, branching, and scores are **operator-owned data** — never hardcode
question text or scoring in the page. The page renders whatever the dashboard publishes.
- Answers may contain personal data: the public endpoints are already rate-limited and
org-scoped server-side. Don't cache responses in the browser.
- Run `npm run verify` before shipping, as with any new page.
Source of truth
This recipe ships inside the template as docs/prompts/build-a-quiz-or-flow.md — this page renders that exact file. Your own copy is always the newest version: take the template and you get every recipe locally, versioned with the release you are on, and each upgrade brings the newer ones with it. How to get it →