Engineering

Conditional Logic in Form Builders: A Schema That Stays Maintainable

A reference design for form conditional logic: rules as a dependency graph, server-authoritative validation, idempotent payment side effects, and versioned schemas.

5 min read

A form builder starts as a list of fields. A few months later it is a list of fields plus invisible rules: this section appears when that answer is "yes", this field becomes required only for business accounts, this total changes when a coupon is applied. The fields are still easy to render. The rules are where the maintenance cost hides.

The design below is a reference architecture for a form system with conditional logic, payments, and email delivery. It is not a report of a BuildVault deployment.

Model the form as data, not interface

If rules live in component props, only one renderer can evaluate them. Put the form in a document instead, so the builder, the runtime, and the server can all read the same definition:

{
  "version": 7,
  "fields": [
    { "id": "account_type", "type": "select", "options": ["personal", "business"] },
    { "id": "company_name", "type": "text" },
    { "id": "vat_id", "type": "text" },
    { "id": "plan", "type": "select", "options": ["starter", "team"] }
  ],
  "rules": [
    {
      "id": "show-company",
      "when": { "field": "account_type", "op": "equals", "value": "business" },
      "then": [{ "target": "company_name", "action": "show" }]
    },
    {
      "id": "require-vat",
      "when": { "field": "plan", "op": "equals", "value": "team" },
      "then": [{ "target": "vat_id", "action": "require" }]
    }
  ]
}

Two decisions matter more than the syntax: give every rule a stable id (so you can log which rule fired), and separate visibility from validation. A hidden field must not be required, and its value must not be submitted — that is a security rule, not a cosmetic one.

Rules form a graph, so evaluate them in order

Rules reference fields, and rules can affect fields that other rules read. Treat the dependency structure as a directed graph, reject cycles when the form is saved, and evaluate in topological order until values stop changing:

type Rule = { id: string; when: Condition; then: Action[] };

export function evaluate(rules: Rule[], values: Record<string, unknown>) {
  const visible = new Set<string>();
  const required = new Set<string>();

  // Rules are pre-sorted by dependency depth when the form is saved.
  for (const rule of rules) {
    if (!matches(rule.when, values)) continue;
    for (const action of rule.then) {
      if (action.action === "show") visible.add(action.target);
      if (action.action === "require") required.add(action.target);
      if (action.action === "hide") visible.delete(action.target);
    }
  }

  // A hidden field is never required, whatever else the rules say.
  for (const id of required) if (!visible.has(id)) required.delete(id);
  return { visible, required };
}

Cap the number of evaluation passes. An unbounded loop that runs until values settle will happily spin on a poorly designed form, and the user sees a frozen tab rather than an error.

Keep validation server-authoritative

Client-side logic exists to make the form pleasant; the server decides whether a submission is acceptable. Send the schema version with the payload, re-run the same evaluator server-side, and re-validate every visible field. If the client claims a field was hidden, check that the rule really hides it under the submitted values.

Shared evaluation code is the point of a document-based form: one implementation, two runtimes. The JSON Schema specification is a reasonable reference for describing field constraints, but conditional visibility remains application logic — do not expect a schema validator to enforce which fields a user was allowed to see.

Payments and email are side effects, so make them idempotent

A submission that charges a card and sends an email is not a single operation. Assume every external call can be retried, duplicated, or delivered late:

  • Create one payment intent per submission and store its identifier before redirecting the payer.
  • Verify payment webhooks by signature, and deduplicate by provider event identifier rather than by amount.
  • Send the confirmation email from a message that records the submission version, so a retry does not send a second copy.

The same at-least-once reality applies to any event pipeline, as described in the real-time infrastructure guide: a consumer may receive a message again, so processing it twice must not double-charge a customer.

Version the schema, not only the submission

Editing a live form changes the meaning of every new submission. Store a version on each submission and keep old schema versions readable. A support request about a form filled in three months ago is unanswerable if the only copy of the rules is the current one.

A testing checklist

  1. Every rule has a test that flips its condition and asserts the resulting visible and required sets.
  2. A form with a cycle is rejected at save time, not at render time.
  3. A hidden field with a value in the payload is dropped by the server.
  4. A duplicated payment webhook produces exactly one charge and one email.
  5. An old submission renders correctly after the form has been edited twice.
  6. The builder and the runtime produce identical visible sets for the same answers — this is the test that catches drift between two implementations.

For how these pages should be described to search engines and social platforms once they ship, see technical SEO for Next.js App Router sites.