Designing the Data Model for a Dynamic Form Builder.
Thursday, September 24th, 2026
Introduction
Online forms are everywhere, from signup and login screens to tax returns and job applications. They share a familiar starting point: ask a question, collect an answer, validate it, and submit it. But they don’t all work the same way.
A login form might need only an email address and a password. Another form might need to ask follow-up questions, collect details about multiple insurance providers, and reuse information across several pages. Both collect answers, but the rules governing those answers are different.
When building a single form, we can write its rules directly into the application. Building a form builder introduces another challenge: someone else needs to configure those rules, and our application needs to interpret them consistently.
What happens when a form becomes more than a collection of input fields and a server to submit them to? How do we describe its structure, express relationships between questions, and keep everything consistent as the form changes?
This post explores the system behind a dynamic form builder - the requirements that shaped its data model, the architecture that brings those definitions to life, and the constraints that keep them valid.
Problem Statement
A basic form follows a straightforward flow:
Display fields -> collect answers -> validate -> submit.
We can represent a simple form as a list of field definitions:
{
"fields": [
{
"id": "name",
"type": "text",
"label": "Full name",
"required": true
},
{
"id": "insured",
"type": "checkbox",
"label": "Do you have insurance?"
}
]
}The application reads each definition, renders the appropriate input, and collects the answer. For a form with independent fields and fixed validation rules, this is a reasonable starting point.
Now let’s add a few requirements.
Some questions depend on earlier answers
Let’s stop and ask ourselves: what if we only want insurance details when the user has insurance?
We can simply make those fields optional, right? Users without insurance can skip them, and we trust, in good faith, that users with insurance will fill them in. But there’s a gap: we need those details, yet nothing in the form requires them. Making the fields required for everyone creates the opposite problem: users without insurance cannot proceed.
What we actually need is a conditional rule. If the user has insurance, ask for the details and require them. Otherwise, let the user continue.
Has insurance? -> Yes -> collect insurance details -> validate -> continue.
Has insurance? -> No -> continue.
That sounds straightforward, but consider this: the user selects “Yes,” fills in the details, then changes their answer to “No.” Do we keep those answers? Clear them? Submit them anyway?
Now the decision affects more than what appears on screen. It also affects validation and the data we save.
Some sections need to repeat
Okay, next: what if the user has multiple insurance providers and we want to collect details for each one?
Do we add ten sets of insurance fields and hope that’s enough? Most users would see boxes they don’t need. And how do we validate them? An empty set might be unused, while a half-filled set might be missing required information.
Instead, let the user add a provider, fill in its details, and add another when needed.
([Add provider -> enter provider details] x N) -> validate all entries -> continue.
Now we have one section definition, but several sets of answers. Changing the policy number for one provider must not change it for another.
You can see how the requirements are growing. We started with a list of inputs. We now need conditional questions and sections that repeat, each with its own answers.
Some answers need to appear more than once
Next, the form has grown to fifty or more inputs across several pages. The user’s name needs to appear in their personal details, an insurance summary, and a final declaration.
Do we ask them to type it every time? And if they correct it on the first page, should they have to find every other place they entered it?
For information that should stay consistent, we need several fields to refer to one underlying answer.
Enter full name -> store one answer -> reuse in [personal details, insurance summary, final declaration].
There’s a distinction here: multiple insurance providers need separate answers, while multiple appearances of the same name need a shared answer. Our model has to support both.
The relationships become part of the form
A flat list can still support these requirements. We could add conditions, grouping information, and references to shared answers to each field. But once we do, the relationships between fields become just as important as the fields themselves.
Those relationships also need to survive editing. What happens when the builder deletes a question that another question depends on? When they duplicate a section, which fields need new identities, and which should still refer to shared answers?
We need to represent those relationships explicitly and keep them valid as the form changes.
This changes what we need to store. A form definition must describe not only which inputs to display, but how they are organized, when they apply, which sections can repeat, and where their answers come from.
The builder needs a way to express those rules, and the runtime needs a consistent way to execute them. That requirement shaped the architecture.
Architecture
I looked at the form as a combination of different parts. First, let’s look at its two views.
The creator’s view
The creator wants a builder. They need to add inputs, configure their settings, arrange them into steps or sections, and define relationships between them.
Creator -> builder UI -> template definition -> template validation -> save or publish.
The builder maintains a draft. Each edit updates that draft, and saving sends the definition to the backend.
The respondent’s view
The person completing the form does not care about the builder’s controls. They want to see their form, fill it, understand any errors, and submit it. They may also want to save and return later.
Dispatched template -> runtime renderer -> answers -> answer validation -> submission.
The runtime maintains their answers, current step, and errors.
Now we have two views. What connects them?
The language of the engine
The creator defines what the form should do, and the runtime needs to understand that definition. Both need a predictable structure to work with.
That structure is the template contract.
Creator -> builder -> template contract -> runtime -> respondent.
The shared form engine provides the rules and behavior behind this contract. The builder produces the definition, and the runtime interprets it.
Think of the template as a small domain-specific language, or DSL. Its vocabulary describes fields, steps, conditions, repeating sections, and variables.
For example, the creator configures an insurance-detail field to appear when a checkbox is selected. The builder records that relationship, and the runtime applies it.
What the form needs to express
We first need to establish the features our language supports:
- Repeating entries with independent answers.
- Variables that let fields share an answer.
- Positioning and arrangement within steps and containers.
- Conditions based on earlier answers.
- Different input types and their settings.
- Validation rules.
- Stable identities for fields and variables.
Duplicating a field in the builder creates another definition. Adding an entry to a repeating section creates another set of answers for the same definition.
We want to be extensible without going overboard. Each feature needs a clear representation and corresponding runtime behavior.
The template structure
steps: where fields appear
steps is an ordered array of pages. Each step has a fieldOrder array containing field IDs.
Rearranging fields changes the order of those IDs. Their definitions and identities stay the same.
Step -> ordered field IDs -> field definitions.
fieldsById: what each field is
fieldsById stores field definitions by ID, giving the builder and runtime direct access to their types, settings, and relationships.
The id identifies the field definition. The key identifies its answer when the field is not bound to a variable. Containers reference child fields by ID, while conditions reference the questions they depend on.
This separates what a field is from where it appears.
variablesById: which fields share an answer
Suppose the patient’s name appears twelve times across seven pages. We want those fields to share one answer.
My first thought was to make one field reference another. But what happens if the creator deletes the field everyone else references?
The idea came from JavaScript’s shared object references:
const variables = {
var_001: { value: "Mr E" }
};
let fieldA = variables.var_001;
const fieldB = variables.var_001;
fieldA.value = "Mr Example";
console.log(fieldB.value); // "Mr Example"Both reference the same object. Removing one reference does not break the other:
fieldA = null;
console.log(fieldB.value); // Still "Mr Example"That is the behavior we wanted. “Add as variable” creates a separate variable definition and binds the original field to it. Other compatible fields can reference that variable too.
Field A ---+
+--> var_001 --> shared answer
Field B ---+Our implementation uses IDs to make that connection:
const nameField = {
binding: { variableId: "var_001" }
};
const declarationField = {
binding: { variableId: "var_001" }
};
const variableValues = {
var_001: "Mr E"
};Both fields read and write variableValues["var_001"].
variablesById stores the definition: ID, key, label, type, and source metadata. The runtime’s variableValues stores the answer.
Neither field owns the variable. Both can read and update it.
Deleting a field leaves the variable available to other bound fields. Unlinking removes only that field’s binding. Deleting the variable itself first unlinks its fields, then removes the definition. Unused variables can also be cleaned up separately.
Putting it together
Here is a shortened example showing the relationships. Metadata and other settings are omitted; these fragments illustrate the structure rather than a complete publishable template.
{
"steps": [
{
"id": "details",
"fieldOrder": [
"full_name",
"has_insurance",
"insurance_notes",
"insurance_providers"
]
},
{
"id": "declaration",
"fieldOrder": ["declaration_name"]
}
],
"fieldsById": {
"full_name": {
"kind": "text",
"binding": { "variableId": "applicant_name" }
},
"has_insurance": {
"kind": "checkbox",
"key": "has_insurance"
},
"insurance_notes": {
"kind": "textarea",
"conditional": {
"parentFieldId": "has_insurance",
"operator": "includes",
"optionValue": "true"
}
},
"insurance_providers": {
"kind": "repeating_section",
"settings": {
"fieldIds": ["provider_name"],
"minItems": 0
}
},
"provider_name": {
"kind": "text",
"key": "provider_name",
"required": true
},
"declaration_name": {
"kind": "text",
"binding": { "variableId": "applicant_name" }
}
},
"variablesById": {
"applicant_name": {
"id": "applicant_name",
"key": "applicant_name",
"label": "Applicant name",
"kind": "text"
}
}
}The steps define order. The notes field depends on the insurance answer. The repeating section references its child field, and both name fields reference the same variable.
The template describes the form. The respondent’s answers are stored separately.
Turning the definition into a form
The runtime receives the template and initializes the answer state.
The renderer resolves the active step’s fields, checks visibility, and selects an input component using each field’s kind. It also handles columns and repeating sections.
A controller coordinates answer changes. An ordinary field updates its own value; a bound field updates its shared variable.
Input change -> update answer -> resolve shared values -> evaluate conditions -> update form.
Two kinds of validation
The creator’s definition and the respondent’s answers need different checks:
Template -> check structure, references, compatible bindings, and cycles -> accept or report errors.
Answers -> check applicable required fields, formats, and repeated entries -> submit or report errors.
The backend also validates incoming requests and enforces access and upload rules.
Each sent form has its own snapshot
Suppose we send Car Insurance Form A to Mr E. The dispatch captures the template at that time. Later edits to Car Insurance Form A do not change Mr E’s snapshot.
Published template -> dispatch snapshot -> recipient’s form.
This preserves the questions and rules associated with his answers. The runtime can interpret that snapshot independently of ongoing edits in the builder.
Returning to a saved form
A respondent can save their progress and return later.
When they reopen the link, the application loads the dispatch snapshot and their latest saved answers, then passes both to the runtime.
Dispatch snapshot + saved answers -> runtime provider -> restored form state.
We restore their answers against the form they started, rather than the creator’s latest edits.
We now have a clear understanding of the system: the builder creates the definition, the runtime interprets it, and the backend stores templates, snapshots, and answers. The template contract connects these parts, while the shared engine provides the rules and behavior.
CREATOR
|
v
Builder UI
|
v
Template definition
|
validate and save
|
v
Backend + storage <----------------+
| |
snapshot + saved answers |
| |
v |
Form runtime |
| |
+--- save / submit --------+
|
v
RESPONDENT
views and answers
Shared engine: template schema + rendering + validationFigure: How the builder, backend, and runtime work through the shared template contract.
With those boundaries established, let’s follow the form through an interaction.
The Form in Action
The respondent opens their link, and the runtime loads the template snapshot and any saved answers.
Selecting “Yes” updates the answer state:
{
"has_insurance": true
}The insurance notes field uses this condition in its definition:
{
"conditional": {
"parentFieldId": "has_insurance",
"operator": "includes",
"optionValue": "true"
}
}Select “Yes” -> update answer -> evaluate condition -> show notes field.
Changing the answer to “No” hides that field, clears its ordinary answer, and excludes it from validation.
Separately, the respondent can add provider entries to the repeating section:
{
"insurance_providers": [
{ "provider_name": "Provider A" },
{ "provider_name": "Provider B" }
]
}Each entry uses the same field definitions but keeps its own answers. Conditional logic on repeating sections is outside the current implementation, so this section is not hidden by the insurance checkbox.
Meanwhile, both name fields use the same binding:
{
"binding": {
"variableId": "applicant_name"
}
}Updating either field updates the shared variable.
Clicking Next validates the current step. Final submission validates applicable answers across the form, excludes hidden fields, and collects shared-variable answers only once. The repeating section becomes one answer in the submission:
{
"fieldId": "insurance_providers",
"key": "insurance_providers",
"value": [
{ "provider_name": "Provider A" },
{ "provider_name": "Provider B" }
]
}Answer questions -> validate -> collect applicable answers -> submit.
Tradeoffs
What did we gain, and what did we take on to get it?
Flexible arrangement, more bookkeeping. Moving fields is easier when their definitions live separately from their positions. But now deleting or duplicating a field means checking its references too.
Shared answers, explicit ownership rules. The user enters their name once, and several fields share it. Nice, but what happens when we delete the variable? We have to unlink those fields so they do not point to something that no longer exists.
Stable forms, changes that do not carry over. Mr E gets the form we sent him, even if the creator edits the template later. The other side? Fixing a mistake in the template does not fix his existing snapshot.
Fewer combinations, less flexibility. We left conditional logic and variable bindings out of repeating sections. That keeps the current rules simpler, but limits what creators can build. Before supporting those combinations, we need to answer: does this reference belong to one entry or all of them?