← Back to Blog

Multi-Step Form Wizards: Building Complex Workflows with FormForge

Long forms drive users away. Research consistently shows that conversion rates drop as the number of visible fields increases. A 20-field registration form feels overwhelming, even when every field is necessary. The solution is to break the form into a wizard: a sequence of focused steps that guide users through the process one section at a time.

FormForge makes building multi-step wizards straightforward. Instead of creating one monolithic form definition, you define each step as a separate schema and use client-side JavaScript to manage navigation between steps. The API generates each step's HTML independently, giving you full control over the flow while FormForge handles the rendering, validation, and accessibility for each individual step.

Why Multi-Step Forms Work Better

Wizard-style forms improve the user experience in several measurable ways:

The tradeoff is implementation complexity. With a single form, you submit once. With a wizard, you need step navigation, per-step validation, data persistence between steps, and a final aggregated submission. FormForge handles the per-step rendering and validation, leaving you to orchestrate the flow.

Designing the Step Schema

The key pattern for multi-step wizards with FormForge is to define each step as its own JSON schema. Each step is a standalone form definition with its own title, fields, and validation rules. You then render each step independently via the API.

Here is a three-step user registration wizard. Step one collects account credentials, step two collects personal details, and step three collects preferences:

Step 1: Account Information

json
{
  "title": "Create Your Account",
  "description": "Step 1 of 3 — Account credentials",
  "theme": "modern",
  "fields": [
    {
      "name": "email",
      "type": "email",
      "label": "Email Address",
      "required": true,
      "placeholder": "you@company.com"
    },
    {
      "name": "username",
      "type": "text",
      "label": "Username",
      "required": true,
      "minLength": 3,
      "maxLength": 30,
      "pattern": "^[a-zA-Z0-9_]+$"
    },
    {
      "name": "company",
      "type": "text",
      "label": "Company Name",
      "placeholder": "Acme Inc."
    }
  ]
}

Step 2: Personal Details

json
{
  "title": "Personal Details",
  "description": "Step 2 of 3 — Tell us about yourself",
  "theme": "modern",
  "fields": [
    {
      "name": "fullName",
      "type": "text",
      "label": "Full Name",
      "required": true
    },
    {
      "name": "phone",
      "type": "tel",
      "label": "Phone Number"
    },
    {
      "name": "role",
      "type": "select",
      "label": "Your Role",
      "required": true,
      "options": ["Developer", "Designer", "Product Manager", "Executive", "Other"]
    },
    {
      "name": "bio",
      "type": "textarea",
      "label": "Short Bio",
      "maxLength": 500,
      "placeholder": "Tell us a bit about yourself..."
    }
  ]
}

Step 3: Preferences

json
{
  "title": "Your Preferences",
  "description": "Step 3 of 3 — Customize your experience",
  "theme": "modern",
  "fields": [
    {
      "name": "plan",
      "type": "radio",
      "label": "Select Plan",
      "required": true,
      "options": ["Free", "Pro ($29/mo)", "Enterprise (custom)"]
    },
    {
      "name": "newsletter",
      "type": "checkbox",
      "label": "Subscribe to product updates"
    },
    {
      "name": "referral",
      "type": "select",
      "label": "How did you hear about us?",
      "options": ["Search Engine", "Social Media", "Friend or Colleague", "Blog Post", "Conference", "Other"]
    }
  ]
}

Each of these schemas is a valid FormForge input on its own. You can render and test each step independently, which simplifies development and debugging.

Rendering Steps with the API

To build the wizard, you render all steps on page load (or lazily as the user advances) and swap the visible step using JavaScript. Here is how to render all three steps in a single batch using JavaScript:

javascript
// Define your wizard steps as an array of FormForge schemas
const steps = [
  { title: "Create Your Account", description: "Step 1 of 3", theme: "modern", fields: [/* ... */] },
  { title: "Personal Details", description: "Step 2 of 3", theme: "modern", fields: [/* ... */] },
  { title: "Your Preferences", description: "Step 3 of 3", theme: "modern", fields: [/* ... */] },
];

const API_URL = "https://formforge-api.vercel.app/api/json-to-form";
const API_KEY = "ff_live_your_key";

async function renderAllSteps() {
  const rendered = await Promise.all(
    steps.map(async (schema) => {
      const res = await fetch(API_URL, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${API_KEY}`,
        },
        body: JSON.stringify(schema),
      });
      const data = await res.json();
      return data.html;
    })
  );
  return rendered;
}

This gives you an array of HTML strings, one per step. The next challenge is wiring up the navigation.

Client-Side Step Navigation

The navigation layer manages which step is visible, handles forward and backward movement, and tracks collected data. Here is a complete wizard controller that works with the rendered FormForge steps:

javascript
class FormWizard {
  constructor(containerEl, stepHtmlArray, onComplete) {
    this.container = containerEl;
    this.steps = stepHtmlArray;
    this.currentStep = 0;
    this.data = {};
    this.onComplete = onComplete;

    this.render();
  }

  render() {
    this.container.innerHTML = `
      <div class="wizard-progress">
        ${this.steps.map((_, i) => `
          <div class="wizard-step-indicator
            ${i < this.currentStep ? 'completed' : ''}
            ${i === this.currentStep ? 'active' : ''}">
            ${i + 1}
          </div>
        `).join('<div class="wizard-connector"></div>')}
      </div>
      <div class="wizard-form-container">
        <iframe
          id="wizard-frame"
          sandbox="allow-scripts allow-forms"
          srcdoc="${this.escapeHtml(this.steps[this.currentStep])}"
          style="width:100%;min-height:400px;border:none;"
        ></iframe>
      </div>
      <div class="wizard-nav">
        ${this.currentStep > 0
          ? '<button id="wizard-back">Back</button>'
          : '<span></span>'}
        <button id="wizard-next">
          ${this.currentStep === this.steps.length - 1
            ? 'Submit' : 'Next'}
        </button>
      </div>
    `;

    this.bindEvents();
  }

  bindEvents() {
    const nextBtn = document.getElementById("wizard-next");
    const backBtn = document.getElementById("wizard-back");

    nextBtn?.addEventListener("click", () => this.next());
    backBtn?.addEventListener("click", () => this.back());
  }

  collectStepData() {
    const iframe = document.getElementById("wizard-frame");
    const form = iframe.contentDocument?.querySelector("form");
    if (!form) return {};

    const formData = new FormData(form);
    const entries = Object.fromEntries(formData.entries());
    return entries;
  }

  validateCurrentStep() {
    const iframe = document.getElementById("wizard-frame");
    const form = iframe.contentDocument?.querySelector("form");
    if (!form) return false;

    // Trigger native HTML5 validation
    return form.reportValidity();
  }

  next() {
    if (!this.validateCurrentStep()) return;

    // Save data from current step
    Object.assign(this.data, this.collectStepData());

    if (this.currentStep === this.steps.length - 1) {
      this.onComplete(this.data);
      return;
    }

    this.currentStep++;
    this.render();
  }

  back() {
    if (this.currentStep === 0) return;
    Object.assign(this.data, this.collectStepData());
    this.currentStep--;
    this.render();
  }

  escapeHtml(str) {
    return str.replace(/&/g, "&amp;")
      .replace(/"/g, "&quot;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
  }
}

Initialize the wizard after rendering all steps:

javascript
async function init() {
  const stepHtml = await renderAllSteps();
  const container = document.getElementById("wizard-root");

  new FormWizard(container, stepHtml, async (allData) => {
    console.log("Wizard complete, submitting:", allData);

    const res = await fetch("https://your-api.com/register", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(allData),
    });

    if (res.ok) {
      container.innerHTML = "<p>Registration complete!</p>";
    }
  });
}

init();

Validation Between Steps

FormForge generates forms with built-in HTML5 validation. Each rendered step includes required attributes, pattern checks, minLength/maxLength constraints, and live ARIA error messages. The wizard's validateCurrentStep() method calls the browser's native reportValidity() on the embedded form, which triggers all of these checks before allowing the user to advance.

For custom validation that goes beyond what HTML5 provides, you can add async checks between steps. A common example is verifying that a username or email is available before proceeding:

javascript
async next() {
  if (!this.validateCurrentStep()) return;

  const stepData = this.collectStepData();

  // Custom async validation for step 1
  if (this.currentStep === 0) {
    const available = await checkUsernameAvailability(
      stepData.username
    );
    if (!available) {
      this.showStepError("That username is already taken.");
      return;
    }
  }

  Object.assign(this.data, stepData);
  // ... proceed to next step
}
Tip: Keep async validation lightweight. Users expect near-instant feedback when clicking "Next." Run expensive checks (like database lookups) only on the step where the relevant field lives, not on every step transition.

Building the Same Wizard in Python

If your backend is Python-based, the pattern is identical. Render each step via the FormForge API, then serve them to your frontend. Here is a Flask implementation that pre-renders all wizard steps and serves them as a JSON array:

python
import os
import requests
from flask import Flask, jsonify, request

app = Flask(__name__)

FORMFORGE_URL = "https://formforge-api.vercel.app/api/json-to-form"
FORMFORGE_KEY = os.environ["FORMFORGE_KEY"]

WIZARD_STEPS = [
    {
        "title": "Create Your Account",
        "description": "Step 1 of 3",
        "theme": "modern",
        "fields": [
            {"name": "email", "type": "email", "label": "Email", "required": True},
            {"name": "username", "type": "text", "label": "Username", "required": True},
        ],
    },
    {
        "title": "Personal Details",
        "description": "Step 2 of 3",
        "theme": "modern",
        "fields": [
            {"name": "fullName", "type": "text", "label": "Full Name", "required": True},
            {"name": "role", "type": "select", "label": "Role", "options": ["Dev", "PM", "Design"]},
        ],
    },
    {
        "title": "Your Preferences",
        "description": "Step 3 of 3",
        "theme": "modern",
        "fields": [
            {"name": "plan", "type": "radio", "label": "Plan", "options": ["Free", "Pro"]},
            {"name": "newsletter", "type": "checkbox", "label": "Subscribe"},
        ],
    },
]

def render_step(schema):
    """Render a single wizard step via FormForge."""
    resp = requests.post(
        FORMFORGE_URL,
        json=schema,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {FORMFORGE_KEY}",
        },
    )
    resp.raise_for_status()
    return resp.json()["html"]


@app.route("/api/wizard-steps")
def get_wizard_steps():
    """Return pre-rendered HTML for every wizard step."""
    rendered = [render_step(s) for s in WIZARD_STEPS]
    return jsonify({"steps": rendered})


@app.route("/api/wizard-submit", methods=["POST"])
def submit_wizard():
    """Receive the aggregated wizard data."""
    data = request.get_json()
    # Validate and save the complete registration
    print(f"Registration data: {data}")
    return jsonify({"status": "ok"})

Your frontend JavaScript fetches /api/wizard-steps on page load, receives the array of pre-rendered HTML strings, and passes them to the FormWizard class shown earlier. The Python backend handles rendering and final submission while the client handles navigation.

Persisting Data Between Steps

The FormWizard class above stores data in memory, which works for simple flows. For longer wizards or cases where users might accidentally close the tab, you should persist step data to sessionStorage so progress survives page refreshes:

javascript
class PersistentFormWizard extends FormWizard {
  constructor(containerEl, stepHtmlArray, onComplete, storageKey) {
    super(containerEl, stepHtmlArray, onComplete);
    this.storageKey = storageKey || "formforge_wizard";
    this.restore();
  }

  save() {
    sessionStorage.setItem(this.storageKey, JSON.stringify({
      currentStep: this.currentStep,
      data: this.data,
    }));
  }

  restore() {
    const saved = sessionStorage.getItem(this.storageKey);
    if (!saved) return;

    const { currentStep, data } = JSON.parse(saved);
    this.currentStep = currentStep;
    this.data = data;
    this.render();
  }

  next() {
    super.next();
    this.save();
  }

  back() {
    super.back();
    this.save();
  }

  clear() {
    sessionStorage.removeItem(this.storageKey);
  }
}

Adding a Progress Indicator

A visual progress bar helps users understand where they are in the flow. The wizard controller's render() method already outputs step indicators. Here is the CSS to style them:

css
.wizard-progress {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 32px;
  gap: 0;
}

.wizard-step-indicator {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 600;
  font-size: 14px;
  border: 2px solid #2a2a3a;
  color: #666;
  background: transparent;
  transition: all 0.2s ease;
}

.wizard-step-indicator.active {
  border-color: #10b981;
  color: #10b981;
  background: rgba(16, 185, 129, 0.1);
}

.wizard-step-indicator.completed {
  border-color: #10b981;
  color: #fff;
  background: #10b981;
}

.wizard-connector {
  width: 48px;
  height: 2px;
  background: #2a2a3a;
}

.wizard-nav {
  display: flex;
  justify-content: space-between;
  margin-top: 24px;
}

.wizard-nav button {
  padding: 10px 24px;
  border-radius: 8px;
  border: none;
  font-weight: 600;
  cursor: pointer;
  font-size: 14px;
  transition: opacity 0.15s;
}

#wizard-next {
  background: #10b981;
  color: #000;
}

#wizard-back {
  background: #1e1e2e;
  color: #e4e4ef;
}

Handling the Final Submission

When the user clicks "Submit" on the last step, the wizard collects data from all steps and sends it to your backend in a single request. The aggregated data object contains every field from every step, keyed by field name:

json
{
  "email": "jane@acme.com",
  "username": "janesmith",
  "company": "Acme Inc.",
  "fullName": "Jane Smith",
  "phone": "+1-555-0123",
  "role": "Developer",
  "bio": "Full-stack developer focused on APIs.",
  "plan": "Pro ($29/mo)",
  "newsletter": true,
  "referral": "Blog Post"
}
Important: Use unique field names across all steps. If two steps both have a field named name, the later step's value will overwrite the earlier one. Prefix field names with the step context (like billing_name and shipping_name) to avoid collisions.

Dynamic Step Counts

Sometimes the number of steps depends on user input. For example, a checkout wizard might skip the shipping address step if the user selects digital delivery. You can handle this by conditionally filtering the steps array before passing it to the wizard:

javascript
function buildSteps(userSelections) {
  const steps = [accountStep, personalStep];

  if (userSelections.needsShipping) {
    steps.push(shippingStep);
  }

  if (userSelections.wantsCustomization) {
    steps.push(preferencesStep);
  }

  steps.push(confirmationStep);

  // Update step descriptions to reflect actual count
  steps.forEach((step, i) => {
    step.description = `Step ${i + 1} of ${steps.length}`;
  });

  return steps;
}

Server-Side Rendering for SEO

If your wizard needs to be indexable by search engines (for example, a public onboarding flow), you can server-render the first step and hydrate the wizard on the client. Your server renders step one via FormForge and embeds the HTML directly in the page. JavaScript then takes over for step navigation:

python
# Flask: render step 1 server-side, lazy-load remaining steps
@app.route("/onboarding")
def onboarding():
    first_step_html = render_step(WIZARD_STEPS[0])
    return render_template(
        "onboarding.html",
        first_step=first_step_html,
        total_steps=len(WIZARD_STEPS),
    )

The remaining steps are fetched asynchronously when the user clicks "Next" for the first time, keeping the initial page load fast while ensuring the first step is visible immediately and crawlable by search engines.

Error Handling and Edge Cases

Production wizards need to handle several edge cases gracefully:

javascript
// Use the History API to support browser back/forward
next() {
  // ... validation and data collection ...
  this.currentStep++;
  history.pushState(
    { step: this.currentStep },
    "",
    `?step=${this.currentStep + 1}`
  );
  this.render();
}

// Listen for browser back/forward navigation
window.addEventListener("popstate", (e) => {
  if (e.state?.step !== undefined) {
    wizard.currentStep = e.state.step;
    wizard.render();
  }
});

Putting It All Together

The multi-step wizard pattern with FormForge follows a clean separation of concerns. FormForge handles form rendering, styling, validation markup, and accessibility for each individual step. Your client-side code handles step navigation, data aggregation, and progress visualization. Your backend handles business logic, async validation, and final submission processing.

This approach scales well. Whether you have three steps or twelve, the pattern remains the same: define each step as a JSON schema, render it via the API, and let the wizard controller manage navigation. Adding a new step is as simple as adding a new schema to the array.

Explore the API documentation for the full field type reference, or try building a wizard with the live demo to see how each step renders.

Build your first wizard today

Get your free API key and create multi-step forms in minutes, not days.

Get Free API Key