Engineering · 19 Sept 2026

When a Form Stops Being Just a Form

How we built one reusable, type-safe, configuration-driven form rendering engine for complex government digital services — without a visual builder, and without rebuilding the UI for every workflow.

Shubham Ravani

Registering a marriage. Filing a legal heir claim. Declaring a live-in relationship. On the surface, each of these is “a form”: collect some fields, validate them, submit.

That framing works for a signup page. It collapses under real civil-registration workflows — the same class of services citizens reach through portals like UCC Uttarakhand.

In practice, these applications are executable domain processes. A field may become required based on an earlier answer. Changing the type of marriage ceremony can change how a solemnisation date validates. An applicant may need to complete Aadhaar e-KYC or DigiLocker verification mid-flow. The same application must later be previewed read-only, resumed from a draft, returned for clarification, and compared against a previous submission during officer review.

We needed one reusable, type-safe form engine for many regulated government workflows — without rebuilding the form UI for every service, and without standing up a visual / no-code form designer.

The service configuration contains the domain knowledge; the form engine contains the rendering behaviour.

For example, a service can define a ceremony selector and reveal an additional field only when the applicant chooses “Others”:

const marriageDetails = {
  id: 'marriage-registration-details',
  name: { en: 'Marriage Registration Details' },
  fields: [
    {
      id: 'typeOfMarriageCeremony',
      name: { en: 'Type of Marriage Ceremony' },
      type: 'select',
      required: true,
      options: [
        { value: 'saptapadi', label: { en: 'Saptapadi' } },
        { value: 'nikah', label: { en: 'Nikah' } },
        { value: 'others', label: { en: 'Others' } },
      ],
    },
    {
      id: 'otherNameCeremonyDetail',
      name: { en: 'Name of the Marriage Ceremony' },
      type: 'string',
      required: true,
      hidden: (values) => values.typeOfMarriageCeremony?.value !== 'others',
    },
  ],
}

Conditional ceremony field · Others selected

Marriage registration form showing the ceremony-name field after Others is selected
Conditional ceremony field · Others selected

The same definition rendered by the shared engine. Selecting “Others” reveals the ceremony-name field without adding service-specific logic to the renderer.

In this post

You will learn:

  • Why many government services share a shape but diverge in every rule that matters — and how that becomes a maintenance problem
  • Why generic schema-form tools were not enough for typed, regulated conditionals and identity flows
  • How a configuration-driven rendering engine separates service rules from presentation
  • How the same definitions drive interactive entry, read-only preview, and change comparison
  • Where we accepted tradeoffs — performance fan-out, testing gaps, nested type inference — and what we would improve next

If you already know why “just use a form builder product” fails for this class of workflow, skip to Architecture.

The problem

Government digital services share a common shape: multi-step applications with nested data, documents, identity checks, and strong validation. They diverge in almost everything else — the fields, the conditions, the legal constraints, the verification steps.

Across our portal, that translated into a concrete scale problem:

  • 14+ distinct services, each with its own multi-step application
  • Hundreds of form configuration files, all needing consistent rendering, validation, and review behaviour
  • Shared concerns that must work the same way everywhere: drafts, previews, clarifications, diffs

Hand-rolling each form’s markup and validation would mean every date-picker bug, every accessibility fix, and every layout change has to be repeated across every service. The only sustainable answer is one engine that every service reuses.

But the difficult problem was never simply “dynamic forms.”

The real challenge was supporting all of the following in one system:

CapabilityWhy it matters
Complex conditional behaviourVisibility, requiredness, and options can depend on earlier answers
Runtime validationApplicants must get clear, immediate feedback on invalid data
Compile-time type safetyConfig authors should catch mistakes before they ship
Nested fieldsHousehold, property, and registration data are structured, not flat
Repeatable fieldsFamily members, dependants, legal heirs, witnesses
Multi-step workflowsLong applications need progress, navigation, and error-aware stepping
API-driven fieldsOptions and actions often come from the server
Identity verificationAadhaar, DigiLocker, and video-KYC sit alongside ordinary inputs
Draft / resumeApplicants leave and come back
Application previewOfficers and applicants review submitted data
Clarification / change comparisonUpdated submissions must be diffed against earlier ones

The renderer should understand concepts such as select, date, required, hidden, and options. It should not need to understand marriage ceremonies, legal heirs, or the rules of any individual government service.

Once you accept that many services must share one rendering engine, the design pressure is clear: service teams need a way to describe fields, behaviour, validation, and steps without touching rendering internals every time a regulation changes. Adding a new civil-registration service should be a configuration exercise, not a UI rebuild.

Why off-the-shelf approaches weren’t enough

The instinctive move for “schema-driven forms” is to reach for something like react-jsonschema-form, uniforms, or a hosted form-builder product. Those tools solve a real class of problems. They weren’t a fit for ours.

What we neededWhy generic builders struggle
Arbitrary conditional logic in TypeScriptMany systems rely on static schemas plus string-expression overlays
Typed callbacks against the form data shapeJSON Schema is dynamic at runtime, but not statically typed against your domain model
First-class async fields and actionsServer-backed options and mutations are often bolted on as custom widgets
Embedded identity flowsAadhaar, DigiLocker, and video-KYC are not generic HTML controls
One schema for types and validationSeparate JSON Schema + AJV stacks drift from application TypeScript types

The decisive requirement wasn’t dynamism alone. It was typed dynamism.

We wanted service authors to write configuration that is flexible enough for regulated conditional behaviour, and checked by TypeScript against the real form data shape. That combination is uncommon. Most off-the-shelf builders give you one or the other.

We also didn’t start from a documented migration off a previous bespoke-forms era. The design is best understood as the architecture that avoids the counterfactual: dozens of independently implemented forms, each reinventing validation, conditionals, and review UX.

Architecture: define once, render everywhere

The system is a three-layer pipeline feeding three experiences:

  1. Form definition — each service describes fields, labels, validation, conditionals, steps, sections, and defaults
  2. Rendering engine — interprets those definitions and maps field types to UI
  3. State and validationReact Hook Form manages values; Zod validates them

Those layers power an interactive form, a read-only preview, and a change comparison — from the same vocabulary.

Form engine architectureService definitions pass through one rendering engine and shared form state to produce three application experiences.
Service form definition
Validation schema
Field vocabulary
Steps & sections
Service rules
Rendering engine
Form engine
Field renderer
Nested / repeatable
State & validation
Form state
Zod validation
Rendered experiences
Interactive form
Read-only preview
Change comparison

Conceptually, the rendering engine does a straightforward translation:

Field typeUI behaviour
stringText input or textarea
dateDate picker
selectDropdown
objectNested group of fields
aadhaar-ekycIdentity verification flow
async-selectServer-backed options

The same definitions also drive the interactive renderer, the preview renderer, and the comparison renderer. Filling an application, reviewing it, and comparing clarifications should all speak the same field vocabulary.

There is no shared widget abstraction between write-path and read-path beyond the shared configuration types. Each representation pattern-matches independently on field type. That duplication is a real seam — and a conscious tradeoff we return to later.

How a form definition becomes a working application

At a high level, the lifecycle looks like this:

  1. A service team authors a form definition: Zod validation schema + field/step configuration.
  2. A route loads that configuration for a given service and passes it into the form engine, optionally with saved defaults.
  3. The engine merges defaults, creates form state, and renders the visible steps and sections.
  4. As the applicant types, validation runs and conditional rules re-evaluate.
  5. On submit, the payload is sanitised and handed to the application’s submit handler.
  6. Later, the same definition can render a read-only preview or a before/after comparison.
From configuration to applicationSix stages turn typed configuration into a validated payload while preserving one field vocabulary.
01

Author configuration

TypeScript + Zod

02

Load engine

Select service definition

03

Merge defaults

Create form state

04

Render visible fields

Steps + sections

05

Validate & re-evaluate

Rules react to answers

06

Submit payload

Sanitise at boundary

Same field vocabulary

Interactive form
Read-only preview
Change comparison

Who owns what:

  • Form configuration — the vocabulary of fields, steps, and callbacks
  • Form rendering engine — lifecycle, stepping, and field-to-widget dispatch for the interactive application
  • Validation — Zod schemas composed per service, resolved through the form library
  • Preview / comparison renderers — alternate representations of the same definitions

Service routes do not recreate rendering, validation wiring, or step navigation. They author configuration and hand it to the engine.

Technology choices

We didn’t invent a form library from scratch. We owned the composition layer and chose mature primitives for the hard parts.

React Hook Form — efficient state for large dynamic forms

Government applications can be large: many field types, multi-step layouts, nested objects, and repeatable arrays. React Hook Form’s uncontrolled-first model means a keystroke in one field does not, by default, re-render the entire form tree. That discipline matters when dozens of controls are on screen.

Zod — one schema, two jobs

Zod gives us something unusually valuable: the same schema provides runtime validation and TypeScript type inference. Field callbacks — for visibility, options, side effects — can be typed against the inferred form data shape. That reduces drift between “what the form contains” and “what the validators check.”

ts-pattern — predictable field-type dispatch

The rendering engine maps each field type to a widget through ts-pattern. The architectural purpose is predictability: given a field’s discriminant (string, select, object, …), there is one clear place that decides what UI appears. Type narrowing inside each branch is a bonus. The primary win is a scannable, central mapping from configuration to representation.

dnd-kit — reordering entries, not authoring forms

dnd-kit is used for one narrow job: reordering entries inside repeatable fields while an applicant fills the form.

It is not used to create a visual / no-code form builder. Forms are authored as TypeScript configuration.

Context, sanitisation, and deep-merge

Some fields need shared application services — async option loading, action buttons that fire mutations, captcha flows. React Context provides those dependencies without drilling an API client through every nested field.

Free-text fields can contain unexpected markup. We sanitise submitted string data once, at the submission boundary, rather than fighting the user’s cursor on every keystroke. That matches the actual threat model — stored XSS via submitted data — without degrading typing UX.

Nested object fields mean defaults themselves can be nested. A shallow merge would silently drop them. Deep-merging config defaults with route-supplied values preserves structure when resuming drafts or pre-filling applications.

Design principles

Several principles show up repeatedly in this architecture.

Keep domain logic outside the renderer. The renderer understands field kinds and UI behaviour. It does not understand marriage law, heirship rules, or geography-specific eligibility. Those belong in service configuration.

Configuration-driven does not have to mean loosely typed. Dynamic systems are often stringly typed. We deliberately kept TypeScript safety across form definitions so that config authors get compile-time feedback when they reference fields that don’t exist or return the wrong shapes from callbacks.

Depend on abstractions instead of form-library internals. Service-specific callbacks sometimes need to read or set other fields — for example, after a successful identity verification. Rather than exposing React Hook Form APIs into the configuration layer, the engine provides a small form facade: get/set fields, set errors, reset sections. That keeps configuration independent of the form library.

Keep form definitions version-controlled. For regulated workflows, code-based configuration is a feature: type checking, code review, git history, predictable deployments. A visual builder would optimise for non-engineer authoring. Our constraint was reviewable, auditable business rules owned by engineers.

Separate definitions from representations. The same field definition can power an interactive control, a read-only preview value, and a highlighted change in a clarification diff. That separation is what lets one configuration serve the full application lifecycle.

Implementation

Field definitions

A form definition is not JSON Schema. It is a TypeScript discriminated union of field configurations, generic over the Zod validation schema for that form.

At the conceptual level, a field looks like this:

{
  id: 'typeOfMarriageCeremony',
  name: { en: 'Type of Marriage Ceremony' },
  type: 'select',
  required: true,
  options: [
    { value: 'saptapadi', label: { en: 'Saptapadi' }, description: { en: '…' } },
    { value: 'nikah', label: { en: 'Nikah' }, description: { en: '…' } },
    { value: 'others', label: { en: 'Others' } },
  ],
}

Ceremony-type options from field configuration

Ceremony-type dropdown showing Saptapadi, Nikah, and Others options
Ceremony-type options from field configuration

The renderer turns the field definition into a required dropdown while preserving service-authored labels and descriptions.

Labels and descriptions are modelled as language maps ({ en: string, … }) so the schema can grow into multilingual content later. Today, the interactive renderer primarily reads English — more on that under challenges.

Helper functions that create form and subform configs are intentionally thin at runtime. Their real job is to anchor TypeScript inference, so everything downstream stays typed against the validation schema.

This is the entire integration surface for a service team: author field and step configuration, hand it to the interactive or preview renderer. Nobody needs to touch rendering internals to ship a new service.

Conditional behaviour

Conditional forms are central to the design. Properties such as hidden, disabled, required, and even options can be either static values or functions of the current form state.

In a marriage registration workflow, ceremony type can affect later validation. The otherNameCeremonyDetail field in the introduction is one example: it stays hidden until the applicant selects “Others.”

Conditional behaviour can also change the choices available to the applicant once another field has a value:

{
  id: 'wifeUKDistrict',
  name: { en: 'District' },
  type: 'async-combobox',
  required: true,
  dependencies: ['wifeState'],
  query: {
    getOptions: async (values, api) =>
      api.choices.getDistricts({ query: { stateUid: values.wifeState?.value } }),
    enabled: (values) => values.wifeState?.value !== undefined,
  },
}

District options dependent on state

District combobox options loaded after a state value is selected
District options dependent on state

Selecting a state makes the relevant district options available without teaching the generic renderer about geography.

The same pattern applies at multiple levels: individual fields, sections / subforms, and entire steps.

Conditional behaviourA change in form state resolves typed rules, then changes how a field, section, or step is presented.

Input

Form value changes

Resolve

Hidden
Disabled
Required
Options

Evaluate typed rules

Output

Hidden
Shown disabled
Shown interactively
Field
Section
Step

Conditional behaviour is not a special feature bolted onto a few widgets. It is a first-class part of the configuration model, re-evaluated as form data changes.

Rendering and validation

The interactive renderer walks the visible fields and dispatches on field type. There is no plugin registry and no dynamic loader. At the current scale — roughly two dozen field types, one internal team — a central pattern match is easier to read and reason about than a plugin architecture.

match(field)
  .with({ type: 'string' }, (field) => <TextInput ... />)
  .with({ type: 'select' }, (field) => <Select ... />)
  .with({ type: 'object' }, (field) => renderNested(field.fields))
  .with({ type: 'aadhaar-ekyc' }, (field) => <AadhaarVerification ... />)
  // ...

Nested object fields recursively reuse the same rendering mechanism. Nesting is not a separate engine; it is the same dispatcher called again with a nested path prefix.

One deliberate escape hatch: unmatched field types currently fall through to “render nothing.” That makes incremental extension easy, but it also means a missing branch in the preview or comparison renderer can fail silently. Exhaustiveness is a process checklist today, not a compiler-enforced contract across all representations.

There is no separate rules engine layered on top of the form library. React Hook Form holds state. Zod validates it. The resolver bridges the two.

When an applicant changes a field:

  1. Form state is updated.
  2. Validation runs.
  3. Conditional rules are re-evaluated.
  4. Dependent fields may appear, disappear, become required, or change options.
  5. Service-specific side effects may run when necessary.

Validation mode is aggressive: change, blur, and submit all participate. That gives immediate feedback, but it also means expensive option-derivation or large forms pay a cost on frequent updates.

Service-specific side effects — recalculating a related date, clearing dependent fields after a verification success — run through field callbacks against the form facade. Those callbacks are deferred slightly so form state commits first; the callback then sees the updated values.

Adding a new validation rule usually requires no engine change: service teams compose more Zod in their own schemas. That is a strength — no bespoke validation DSL — with the tradeoff that reusable validation lives in shared schema helpers rather than a central rule catalogue.

Nested and repeatable fields

Government forms rarely map cleanly onto a flat list of inputs. Applicant details, address blocks, registration particulars, and property information are structured. Nested fields exist so the data model can mirror that structure. An object field contains child fields; those children render through the same engine. From the applicant’s perspective, it feels like a grouped section. From the engine’s perspective, it is recursion over the same configuration vocabulary.

fields: [
  { id: 'wifeState', name: { en: 'State' }, type: 'async-combobox', required: true },
  {
    id: 'wifeUKDistrict',
    name: { en: 'District' },
    type: 'async-combobox',
    dependencies: ['wifeState'],
  },
  { id: 'wifeTehsil', name: { en: 'Tehsil' }, type: 'async-combobox', required: true },
  { id: 'wifeUKPinCode', name: { en: 'PIN Code' }, type: 'string' },
  {
    id: 'wifeUKFullAddress',
    name: { en: 'Full Address' },
    type: 'string',
    required: true,
  },
]

Nested address subforms

Present and permanent address sections grouped as structured subforms
Nested address subforms

Structured address data is presented as one grouped experience while remaining nested in form state.

Many workflows also need lists: family members, dependants, legal heirs, witnesses. A field marked as repeatable becomes an array in form state. Applicants can add entries, remove them, and reorder them.

{
  id: 'detailsof1stWitness1',
  name: { en: 'Details of Witness' },
  type: 'object',
  required: true,
  multiple: true,
  fields: [
    { id: 'aadhaarNumberOfWitnessOne', type: 'aadhaar-ekyc', required: true },
    {
      id: 'nameOfWitnessOne',
      type: 'string',
      name: { en: 'Name of the Witness' },
      disabled: true,
    },
    {
      id: 'mobileNumberOfWitnessOne',
      type: 'string',
      name: { en: 'Mobile No' },
      required: true,
    },
  ],
}

Adding a repeatable witness entry

Repeatable witness section with an Add Entry control
Adding a repeatable witness entry

Witness entry with identity verification

Witness entry form with Aadhaar e-KYC and personal details fields
Witness entry with identity verification

Reordering witness entries during fill

Reordering a witness entry using the drag handle
Reordering witness entries during fill

Each witness is a structured repeatable entry. Add, delete, reorder, and verification all reuse the same field vocabulary.

Individual entries can also be locked against deletion or reordering when a “primary” row must remain fixed.

API-driven and identity-verification fields

Not every field is a local input. Some select options come from the server and may depend on earlier answers. Some “fields” are actions: buttons that fire a mutation and then update other values through the form facade. Others embed full identity flows — Aadhaar e-KYC, DigiLocker verification, video capture / video-KYC — with session state and server round-trips.

These are first-class field types, not afterthought widgets. Regulated applications treat verification as part of the form, not a separate side quest bolted on after submit.

{
  id: 'husbandAadhaarNumber',
  type: 'aadhaar-ekyc',
  name: { en: 'Aadhaar Number of Husband' },
  required: true,
  onSuccess: (values, data) => {
    values.setField('husbandName', data.name)
    values.setField('husbandBirthDate', dayjs(data.dob).toDate().toISOString())
    values.setField('husbandAadhaarAddress', formatAadhaarAddress(data.address))
  },
}

Aadhaar e-KYC · before verification

Aadhaar e-KYC panel before verification with consent, captcha, and Send OTP
Aadhaar e-KYC · before verification

Aadhaar e-KYC · after verification

Aadhaar e-KYC after verification with verified UID and populated identity fields
Aadhaar e-KYC · after verification

The verification flow sits alongside ordinary fields. Before verification, the applicant completes consent and OTP. After success, the onSuccess callback populates the read-only identity fields below. Shared services reach these fields through context rather than prop drilling, which keeps nested and repeatable layouts manageable.

Not a visual form builder

The name “form builder” invites the wrong mental model.

This system is not a drag-and-drop form-authoring product. There is no canvas that writes schema. Engineers author forms as TypeScript configuration, review them in pull requests, and ship them through normal deployments.

That choice fits regulated workflows:

  • business rules are code-reviewed
  • type checking catches many config mistakes early
  • version history explains why a field changed
  • behaviour is reproducible across environments

dnd-kit’s role — reordering repeatable entries during data entry — should not be confused with no-code form design. The “builder” in the name means runtime construction of a form from configuration, not a visual designer.

Supporting the complete application lifecycle

Rendering the input form is only part of the problem. The same configuration must support the full lifecycle:

ExperienceRole
Filling an applicationInteractive renderer with validation and conditionals
Saving / resumingDefaults merged into form state
Read-only previewPreview renderer for submitted snapshots
Officer reviewSame field vocabulary, presentation suited to reading
ClarificationApplicants update specific answers
Change comparisonComparison renderer highlights added / updated / deleted values
One configuration, three experiencesInteractive entry, read-only review, and clarification comparison share definitions but render independently.

Form configuration

FieldsRulesValidation

Write path

Interactive form

  • Fill and validate
  • Save or resume
  • Conditional guidance

Read path

Read-only preview

  • Review snapshot
  • Officer reading
  • Consistent labels

Review path

Change comparison

  • Clarification updates
  • Highlight diffs
  • Added / changed / removed

Shared field vocabulary

Sharing one field vocabulary across these experiences prevents the classic failure mode: interactive forms and review screens drifting into two incompatible models of “what an application contains.”

The cost is representational duplication. Interactive controls, read-only values, and diffs are different outputs. Collapsing them into one abstraction would likely produce an awkward least-common-denominator API. We accepted parallel renderers over a forced unification — and we pay for that with careful review when adding field types.

Challenges

Keeping nested forms type-safe

Strong typing is relatively straightforward for flat forms. Nested objects and arrays make TypeScript inference much harder: a child field’s callbacks should be typed against the nested schema, not the root form blob.

We invested in recursive conditional types to thread nested Zod shapes through object fields. That preserves a lot of safety — and it is also the densest type-level code in the module. In places, the renderer still carries an explicit acknowledgement that nested inference is incomplete or fragile.

Resolution so far: keep the type safety where it holds, accept a scoped gap for the hardest nested cases rather than blocking shipping, and treat the remaining inference debt as live technical work — not a solved problem.

Supporting multiple representations

The same field may need interactive rendering, read-only rendering, and diff / change rendering. Those outputs differ enough that a single widget interface would fight the use cases. We kept independent dispatch for each representation.

Resolution: accept duplication, document the update checklist when adding a field type, and rely on review rather than a shared plugin registry.

Conditional flexibility vs performance

Allowing hidden, disabled, required, and options to depend on arbitrary form values is powerful. It is also hard to optimise. If a predicate can read any field, the engine cannot easily know which subscriptions matter.

Today, visibility logic watches whole-form state at multiple levels. That is the simple, correct behaviour for unrestricted predicates — and it is also the clearest scaling risk for the largest multi-step services.

Not fully resolved yet. More targeted subscriptions are the natural next step, but they likely require either declared dependencies or static analysis of predicates.

Multilingual design ahead of multilingual rendering

Every label, name, and description is shaped as a language map. The schema can accept additional locales without a migration across hundreds of configs. The current renderer primarily uses English. That is forward-compatible schema design with an acknowledged runtime limitation: the types look more multilingual than the running system currently is.

Performance and testing

Flexibility has a runtime cost. Because conditional rules can depend on any part of the form, the engine currently re-evaluates visibility with whole-form subscriptions. A change in one section can cause unrelated sections to re-check their predicates.

Form-state subscription fan-outWhole-form subscriptions favour unrestricted rules; scoped dependencies would reduce unrelated re-evaluation.

Today — whole form

One value change wakes every subscribed layer.

One change
Recheck steps
Section A
Section B
Section N

Future — scoped

Declared dependencies wake affected sections only.

One change
Affected section
Dependent options

For the largest service configs — the ones with many steps and sections — this fan-out is the clearest future optimisation lever. Scoping subscriptions to the fields a given section actually depends on would cut recomputation without changing how authors write most configuration.

Other performance decisions cut the other way:

  • React Hook Form’s uncontrolled-first model already limits per-keystroke re-renders to the affected control
  • Memoisation around step/section filtering and field rendering reduces avoidable work
  • Validation is not debounced; immediacy wins over throttling today
  • Long repeatable lists are not virtualised; every entry renders

None of these are invisible tradeoffs. They are the current balance between authoring flexibility and runtime cost.

Today, confidence comes mostly from TypeScript, which protects configuration shapes and many callback contracts, and Storybook, which visually exercises the interactive, preview, and comparison renderers against sample configs.

What we do not yet have is automated behavioural coverage for the engine itself. Compile-time safety does not catch a date picker showing the wrong month, a visibility predicate evaluating incorrectly when optional context is missing, or a new field type rendering in the interactive form but silently disappearing in preview.

High-value tests would focus on behaviour, not brittle snapshots: conditional visibility across representative form states, required / disabled rule resolution, validation behaviour for shared patterns, field-type coverage across interactive, preview, and diff renderers, and consistency checks when a new field variant is added.

Type safety and Storybook are useful. They are not a substitute for regression tests around the conditional engine and cross-renderer coverage.

Tradeoffs and lessons

Configuration-driven does not mean loosely typed. Dynamic form systems can still maintain strong compile-time guarantees. The cost is generic complexity; the benefit is catching many config mistakes before they reach production.

Keep domain rules outside generic infrastructure. The engine should not understand individual government services. Once domain concepts leak into the renderer, every new service becomes a framework change.

Choose abstractions based on actual extension needs. A plugin registry is not automatically better. At our current number of field types and team shape, a central dispatcher is easier to navigate. That calculus can change if the type count keeps growing or multiple teams start extending the engine independently.

Flexibility creates runtime complexity. Arbitrary conditional functions are excellent for expressing regulated behaviour. They make dependency tracking harder. Authoring power and runtime optimisation pull in opposite directions.

Type safety does not replace behavioural testing. Compile-time guarantees and runtime correctness solve different problems. We leaned hard on the former; the latter still needs investment.

Avoid premature no-code tooling. For regulated workflows, code-based configuration can provide stronger reviewability and control than a visual builder. “Form builder” does not have to mean “drag fields onto a canvas.”

Silent fallthrough is convenient — and costly. Allowing unmatched field types to render nothing makes incremental work easy. It also hides missing preview/diff support. Extensibility without exhaustiveness checks shifts the burden onto reviewers.

Sanitize at the boundary that matters. Applying HTML sanitisation on submit, not on every keystroke, matches the threat model and preserves UX. Over-applying mitigations everywhere is not the same as applying them where they count.

What we’d improve next

If we continue investing in this engine, the highest-leverage next steps are clear:

  1. Automated behavioural tests for conditional visibility, validation interactions, and cross-renderer field coverage
  2. Scoped subscriptions so large multi-step forms re-evaluate only the sections that depend on a changed value
  3. A clearer plan for nested type inference — either strengthen it or intentionally simplify it where the complexity no longer pays for itself
  4. Actual multilingual rendering — the schema is ready; the renderer should consume locales rather than assuming English
  5. Stronger exhaustiveness across representations so adding a field type cannot silently skip preview or comparison support

None of these are hidden surprises. They are the natural next constraints once a system is already powering many real services.

Conclusion

We needed one form engine for many regulated government workflows — not because “dynamic forms” are trendy, but because rebuilding UI, validation, preview, and clarification behaviour per service does not scale.

The architecture that emerged is straightforward to state and nontrivial to execute:

  • service configuration owns domain knowledge
  • the rendering engine owns presentation behaviour
  • React Hook Form and Zod own state and validation
  • the same definitions support interactive entry, preview, and comparison

The hard parts were the ones that don’t show up in a todo-list form library: deep conditionals, nested and repeatable structures, identity verification mid-flow, type safety across dynamic configuration, and lifecycle support beyond the initial submit button.

The system is deliberately narrow in some places — no visual authoring canvas, no plugin marketplace, no claim of perfect nested inference — and deliberately ambitious in others: typed configuration, first-class verification fields, and one vocabulary across the application lifecycle.

That combination has let us treat “add a new service” as configuration work. The next gains will come less from new abstractions, and more from testing, performance scoping, and finishing the promises already encoded in the design.

Further reading

  • UCC Uttarakhand — Services registration portal — live state portal for Uniform Civil Code service registration
  • react-hook-form — uncontrolled-first form state management for large dynamic forms
  • Zod — TypeScript-first schema validation used as both compile-time type and runtime validator
  • ts-pattern — pattern matching for TypeScript field-type dispatch
  • @dnd-kit — drag-and-drop primitives used for repeatable-field reordering during fill
  • @hookform/resolvers — bridges Zod schemas into React Hook Form
  • react-jsonschema-form — the schema-form approach we evaluated and did not adopt for typed regulated workflows
Next article
One Project, Multiple Systems, Different Data Models
Start a project

You imagine,
we build.

Tell us about the platform your institution needs. We'll bring the engineering rigor to make it real, and keep it running.