Forms outlive the platforms they were built on
The manufacturer ran hundreds of business-critical forms on a proprietary ERP forms platform: leave requests, security entries, purchase approvals, plant operations. The forms worked, which was precisely the difficulty. They ran the business every day, they were tied to a vendor runtime the company no longer wanted to license, the interface had aged visibly, and the people who understood the platform had moved on.
A form on a platform of this kind is not a layout. It is a declarative specification spanning four planes, and a faithful modernization has to preserve all four: the models it binds to and their types, the view of controls, grids and layout that users see, the behaviour scripts that run as people interact with it, and the lifecycle actions that fire on load, save and submit.
Multiply that by several hundred screens and the problem stops being one of effort. It becomes one of consistency, because no two developers reading the same legacy form will interpret it identically.
Every obvious approach fails, and fails expensively
- Hand-rewriting. The client's own developers estimated two days per form. Across the estate that is a headcount decision rather than a project, and the output drifts: fields are missed, validation is interpreted differently, and the inconsistency only surfaces in testing.
- Wrapping the old runtime. An iframe around the legacy platform preserves the licence bill, the lock-in and the dated interface. It relocates the problem rather than solving it.
- Naive conversion. A tool that translates what it recognises and skips what it does not will produce something that renders. The gap surfaces during user acceptance testing, or in production.
- Black-box generation. Output no developer can read or extend is simply a new kind of lock-in, swapping a runtime the client could not change for a codebase they cannot either.
The economics are what actually decide this. At two days a form, the estate represents roughly 370 developer-days of work with no new functionality at the end of it. That is a business case no sponsor approves, which is why estates like this one sit on a platform long after everybody agrees they should not.
What the environment fixed before we started
- Nothing could be silently dropped. A form that renders but has quietly lost a validation rule is more dangerous than one that fails to convert at all, because nobody will look for the missing rule.
- The output had to be code the client's engineers would maintain. Generated code that no developer will willingly touch is a second legacy system with a shorter fuse.
- No vendor frontend library could survive into the output. The whole point of the exercise was to leave the platform, not to depend on it from a distance.
- Custom business logic could not be guessed. The behaviour scripts encoded rules that existed nowhere else, and a machine translation of them that was subtly wrong would be worse than no translation at all.
- The migration had to be re-runnable. The source estate was still live and still changing during the programme.
A compiler, with two intermediate representations
The engine does not translate a form line by line. It parses it, understands it, and re-expresses it — which is the difference between a converter and a compiler, and it is the reason the output is readable.
The first stage produces a Source IR: a lossless mirror of the legacy form, keyed on the original control type. Because it is keyed on the source vocabulary rather than the target's, the engine physically cannot drop a construct it does not yet understand. Anything unrecognised is still present in the intermediate representation, still counted, and still reported.
Canonicalization passes then fold that into a Semantic IR, an idiomatic tree shaped like the target rather than the source. The passes are named for what they do: mapping one construct onto another, unwrapping needless nesting, absorbing a wrapper into the thing it wraps, and replacing a legacy idiom with the modern construct that expresses the same intent. React is generated from the Semantic IR.
All of it is governed by a typed, cascading policy ruleset the client can override. A quirk specific to one estate is handled by adding a rule, not by forking the engine, which is what stops a second client from becoming a second product.
Deterministic by design
The same form definition produces the same React on every run. That property does more work than it appears to. It means the migration can be re-run as the source evolves rather than being a one-way door, and it means a reviewer can diff two runs instead of re-reading the whole output. When the policy ruleset changes, the effect of that change is visible as a diff across every affected form at once.
The line between structure and logic
Most of a form is structural: its fields, its validation, its grids, its layout. Structure can be translated deterministically, and the engine emits it as native React using MUI, React Hook Form and typed service hooks over TanStack Query.
The custom business logic is the genuinely hard part, and it is the one place where silent automation is dangerous. So the engine draws a hard line and never executes a custom script. Each one becomes a typed stub handler in the generated component, plus read-only reference documentation placed alongside the form it belongs to. A developer finishes it by hand, with the original in front of them and the context intact.
That division is the honest one. A tool claiming fully automated conversion of custom business logic is either wrong or producing code somebody will quietly rewrite later. Capturing everything, and being precise about which part still needs a person, is what makes the numbers trustworthy.
The two-stage pipeline
The migration report
Every construct in a source form is accounted for. It is either translated into React or explicitly recorded as preserved for a developer to complete, and the report states which, per construct, per form. The count is exact.
This is the artifact that makes the programme governable. A programme owner can take a number to a steering committee before cutover and know it is not an estimate. Nothing disappears between the old platform and the new one without appearing in that report.
Built as a tool the client operates, not a service we perform
The engine could have been run by us, form by form, with the output delivered as a series of pull requests. That would have been faster to start and would have left the client dependent on us for every subsequent form, every ruleset change and every correction as the source estate continued to evolve.
Instead the engine ships as a tool their developers operate. A form is selected, compiled, and its migration report reviewed. The generated React is inspected in a live preview beside the original. The policy ruleset is theirs to extend as the estate reveals new quirks, and it is versioned and exportable rather than embedded in the engine.
The remaining custom logic is completed by a developer against the reference documentation the engine produced, which is what the second half of a day per form is actually spent on. That work is real, it is skilled, and it is deliberately left to a person.
What changed
187 forms and a dashboard portal have been migrated for the first client. Hand-migration had been estimated by their own developers at two days per form. With the engine, a developer completes two forms per day: the structural majority arrives generated, and the remaining custom logic is finished by hand in roughly half a day with the reference documentation alongside it.
Across 187 forms that is the difference between roughly 370 developer-days and roughly 95. The estate stopped being a rewrite nobody would fund and became a schedule somebody could actually run — which is the outcome that mattered, because the alternative was not a slower migration but no migration at all.
The generated application depends on React, MUI and the client's own conventions, with no vendor frontend library anywhere in the output and no runtime shim underneath it. The client owns the engine, the policy ruleset, the generated code, and the route off the platform they were leaving.
What it is built on
The target stack was the client's, not ours. The output had to look like code their team already writes, because the measure of the engine is whether they will maintain what comes out of it.
Lessons
The decisive design choice was keying the first intermediate representation on the source vocabulary rather than the target's. It is a small decision with a large consequence: it makes silent loss structurally impossible, because a construct the engine does not understand still has somewhere to live. Every converter that quietly drops what it does not recognise made the opposite choice, usually without noticing.
The second lesson is that admitting the limit is what makes the tool trustworthy. The engine could have attempted the custom business logic and produced something plausible. It would have been wrong occasionally, in ways nobody could easily detect, and the entire output would then have been suspect. Drawing a hard line and reporting exactly what fell on the other side of it is why a programme owner can take the number to a steering committee.
The third is economic rather than technical. The engineering here did not make a possible project faster. It made an impossible project possible, by moving it from 370 developer-days to 95 and turning a business case that no sponsor would approve into one that was straightforward. That is usually where the value of a tool lies, and it is why building the compiler was cheaper than doing the work.