---
url: https://formhaus.dev/recipes/quiz-funnel.md
description: >-
  Build a quiz or lead-capture funnel with Formhaus: one-click radio answers
  with autoAdvance, routes per answer, lead saving and analytics events.
---

# How do I build a quiz or lead-capture funnel?

Formhaus describes the funnel as a multi-step JSON definition: radio questions with `autoAdvance: true` move on as soon as the visitor clicks an answer, `routes` send each answer to its next question, and a contact step collects the lead. `FormRenderer` reports every step and field event through `onAnalyticsEvent`, and lifecycle hooks save the lead before the visitor reaches the last step.

## Definition

The plan quiz from [`examples/react-quiz`](https://github.com/ignsm/formhaus/tree/main/examples/react-quiz). Team visitors get an extra question about team size, solo visitors skip it.

```json
{
  "$schema": "https://formhaus.dev/schema/form-definition.json",
  "id": "plan-quiz",
  "title": "Find your plan",
  "submit": { "label": "Book a call" },
  "steps": [
    {
      "id": "audience",
      "title": "Who is it for?",
      "next": false,
      "fields": [{
        "key": "audience", "type": "radio", "label": "Who will use it?", "autoAdvance": true,
        "helperText": "Click an answer or press Space to continue.",
        "validation": { "required": true },
        "options": [{ "value": "solo", "label": "Just me" }, { "value": "team", "label": "My team" }]
      }],
      "routes": [
        { "to": "team", "show": [{ "field": "audience", "eq": "team" }] },
        { "to": "goal" }
      ]
    },
    {
      "id": "team",
      "title": "How big is your team?",
      "next": false,
      "fields": [{
        "key": "teamSize", "type": "radio", "label": "Team size", "autoAdvance": true,
        "helperText": "Click an answer or press Space to continue.",
        "validation": { "required": true },
        "options": [{ "value": "small", "label": "2–10" }, { "value": "medium", "label": "11–50" }, { "value": "large", "label": "51+" }]
      }],
      "routes": [{ "to": "goal" }]
    },
    {
      "id": "goal",
      "title": "What do you need first?",
      "next": false,
      "fields": [{
        "key": "goal", "type": "radio", "label": "Main goal", "autoAdvance": true,
        "helperText": "Click an answer or press Space to continue.",
        "validation": { "required": true },
        "options": [{ "value": "leads", "label": "Collect leads" }, { "value": "surveys", "label": "Run surveys" }, { "value": "onboarding", "label": "Onboard customers" }]
      }]
    },
    {
      "id": "contact",
      "title": "Where should we send your plan?",
      "next": { "label": "See my plan" },
      "fields": [
        { "key": "email", "type": "email", "label": "Work email", "validation": { "required": true, "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "patternMessage": "Enter a valid email" } },
        { "key": "consent", "type": "checkbox", "label": "I agree to receive my plan by email", "validation": { "required": "Please confirm to continue" } }
      ]
    },
    {
      "id": "call",
      "title": "Want a walkthrough?",
      "fields": [
        { "key": "phone", "type": "phone", "label": "Phone (optional)" }
      ]
    }
  ]
}

```

## React

```tsx
import { FormRenderer } from '@formhaus/react';
import type { FormDefinition, StepChangeContext } from '@formhaus/core';
import { useRef } from 'react';
import quiz from './quiz.json';
import { saveLead } from './api';
import { track } from './analytics';

const definition = quiz as FormDefinition;

export function PlanQuiz() {
  const leadId = useRef<string | null>(null);

  async function captureLead({ fromStepId, direction, values }: StepChangeContext) {
    if (fromStepId !== 'contact' || direction !== 'next') return;
    leadId.current = await saveLead(leadId.current, values);
  }

  return (
    <FormRenderer
      definition={definition}
      onBeforeStepChange={captureLead}
      onAnalyticsEvent={(event) => track(event.type, event)}
      onSubmit={async (values) => {
        leadId.current = await saveLead(leadId.current, values);
      }}
    />
  );
}
```

## Vue

```vue
<script setup lang="ts">
import { FormRenderer } from '@formhaus/vue';
import type { FormDefinition, StepChangeContext } from '@formhaus/core';
import quiz from './quiz.json';
import { saveLead } from './api';
import { track } from './analytics';

const definition = quiz as FormDefinition;
let leadId: string | null = null;

async function captureLead({ fromStepId, direction, values }: StepChangeContext) {
  if (fromStepId !== 'contact' || direction !== 'next') return;
  leadId = await saveLead(leadId, values);
}

async function bookCall(values: Record<string, unknown>) {
  leadId = await saveLead(leadId, values);
}
</script>

<template>
  <FormRenderer
    :definition="definition"
    :on-before-step-change="captureLead"
    :submit-handler="bookCall"
    @analytics-event="(event) => track(event.type, event)"
  />
</template>
```

## How it works

* Each question step has `"next": false` and one radio with `"autoAdvance": true`. Clicking an answer or pressing Space or Enter validates the step and moves on. Arrow keys only select.
* `routes` on `audience` send `team` to the `team` step and everyone else to `goal`. The `team` step routes back to `goal`, so both paths converge.
* Progress counts the active path: four steps for solo visitors, five for teams.
* `onBeforeStepChange` runs after the contact step validates. Saving the lead there captures the email before the optional phone step. When the save throws, the visitor stays on the step and the error goes to `onError`, or above the form when `onError` is not set.
* `onSubmit` updates the same lead id, so Back and Continue never create a second lead.
* `onAnalyticsEvent` emits `step_viewed`, `step_completed`, `step_skipped`, `field_focused`, `field_blurred`, `field_error` and `form_submitted`. Feed them to your analytics tool to build the funnel report.
* The final step never auto-submits, even with `autoAdvance` radios.

## Related

* [Multi-Step Forms](/guide/steps#advance-when-an-answer-is-activated): `autoAdvance`, `next: false` and lifecycle hooks
* [Custom Actions & Progress](/guide/custom-components#analytics-events): analytics events
* [Multi-step branching](/recipes/multi-step-branching): routes with Continue buttons and a skip step
* [Examples README](https://github.com/ignsm/formhaus/tree/main/examples): run the quiz locally
