Skip to content

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. 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.