Polymer to React/TS CMS migration
- Role
- Lead engineer — engine owner
- Stack
- React 18 · TypeScript · Ant Design · React Router v6 · Webpack
- Scope
- ~11k lines of TypeScript across ~95 modules, replacing a ~10k-line Polymer 3 app · 23 schema-driven field types resolved by 20 components · one deployment serving several sites with per-tenant locale sets
An internal content management system — used daily by editorial teams to author structured, multi-locale content — was built on Polymer 3, a framework with no upgrade path and a shrinking hiring pool. The system has no hand-written forms: content types are user-defined schemas fetched from the server at runtime, so the entire editing UI is generated from a field-definition tree. The migration was really a rewrite of that engine, and everything hard about it follows from three properties — the schema is data, not code; every record exists in parallel per locale; and fields nest arbitrarily deep.
server-owned schema ──▶ FormEngine ──▶ FIELD_COMPONENT_MAP ──▶ 20 components
(per content type) │ 23 field types │
│ │
│ Nested ───────────────────
│ └─ re-enters the engine
│ (forms in drawers in forms)
▼
per-locale tabs · the form owns { locale: values } for every tab
complex fields also get multiLangValues + onMultiLangChange
│
▼
FormState ── validation keyed (formId, locale) ── saveAll(): children firstDecisions worth defending
React over Lit 3, after taking the web-component path seriously. The obvious continuation was Lit 3 — modern, standards-based, and the direct descendant of the Polymer model the app was already written in, which made it the shortest conceptual distance from the existing code. It was a genuine contender and it lost on one argument: this is an internal tool with no design resource attached to it. React has a mature component-library ecosystem; the web-component route would have meant building the widget layer ourselves — trees, drawers, tables, date pickers — before a line of the form engine got written. Choosing React meant the whole budget could go into the engine and the UI could arrive from a component library without a designer in the loop. (The same argument runs the other way on the public site, where a design system built on custom elements already existed — there, Lit was the right answer.)
A declarative registry, not imperative DOM construction. The Polymer version built fields with document.createElement(componentMap[type].name), then pushed attributes onto the element and appended it to the DOM. The React port keeps the registry idea and drops the imperative half — a FIELD_COMPONENT_MAP resolving 23 schema types to components, plus a config-driven prop bundle that lets one implementation serve several types. Twenty components cover twenty-three types precisely because attrs exists: rich text and its cut-down variant are one editor, and an embedded object and a stored reference are one picker in two modes. A field type is added by adding a row, never by touching the renderer:
const FIELD_COMPONENT_MAP: Record<string, FieldComponentConfig> = {
String: {component: StringField},
Html: {component: HtmlField, attrs: {type: 'full'}},
HtmlLite: {component: HtmlField, attrs: {type: 'lite'}},
Ref: {component: ContentField, attrs: {isRef: true}},
Nested: {component: NestedField},
// …
};
const Field = FIELD_COMPONENT_MAP[field.type].component;
return <Field {...fieldProps} />;An unknown field type renders a placeholder, not an exception. The schema is user-authored data that ships independently of the client, so a content type can legitimately reference a type this build doesn't know. The renderer treats that as data it doesn't understand yet: it warns, renders a visible marker in place of the field, and keeps the rest of the form editable. Throwing would let one unrecognised row take down an entire content type's editing UI — and the person who would hit it is an editor mid-draft, not the engineer who caused it.
Types stop at the schema boundary, deliberately. The value of a field is whatever a user-defined content type says it is, resolved at runtime — no static type can describe it, and generating types per content type would couple the client's build to editorial data. So typing is applied where it pays: the field-definition contract, the registry, and the props flowing between engine and field are strictly typed, while the value remains untyped by construction. Being explicit about where the type system stops is what keeps the rest of it honest, rather than scattering casts through components that pretend to know more than they do.
Sibling coordination is an explicit contract instead of DOM traversal. Every content record exists in parallel per locale, edited via tabs. Simple fields are locale-scoped, but eight of the field types — arrays, dictionaries, nested groups, references, choice fields, raw JSON — must be edited across all locales at once: you add one array row, and every locale gets a corresponding entry. Polymer did this by having fields find their siblings through the DOM and mutate them directly, which has no React equivalent. Here the parent owns the data — the form holds {locale: values} for every tab, and complex fields receive multiLangValues plus an onMultiLangChange callback — with a shape transposition at the drawer boundary:
storage: {en: [{...}], de: [{...}]} // locale → rows
editing: [{en: {...}, de: {...}}] // row → localeUnidirectional flow, no hidden coupling between components, and the transposition is a pure function that's trivially testable. It also has to be total, not just correct on well-formed input: locales drift out of sync in real data, so the editor reconciles to the longest array and pads the short locales with empty objects, then drops rows that are empty in every locale on the way back out. Legacy content that predates a locale being added is the normal case, not the exception.
Recursion is resolved at render time, not at import time. Nested fields are forms inside forms, so the engine renders a component that renders the engine — a genuine module cycle. The nested field therefore resolves the engine when it renders rather than through a static import at the top of the file, which is what lets the cycle exist at all without either side observing the other as undefined. It is one line, and it carries a comment saying exactly why it is written that way.
One save that spans several independent forms. Nested drawers mean several form instances can be open at once, each with its own set of locale tabs, and a single “save” has to commit all of them. A FormState singleton tracks validation keyed by (formId, locale), lets each mounted form register a save function and receive a status callback, and on saveAll() refuses to start if any locale of any form is invalid, then commits in reverse registration order so children persist before their parents. Form identity comes from context rather than a prop, so a deeply nested engine and the drawer controls that save it agree on who they are without threading an id through every intermediate component. Registration count also drives the affordance — “Save All” appears only once more than one form is registered, so the button never offers to do something there is only one of.
Validation is per-type, because emptiness isn't one predicate. Required-ness means a non-empty string for a text field, a populated link for a URL, and a populated url for an upload — three different shapes behind one schema flag. Constraints run the other way: patterns and numeric bounds are authored by users as part of the schema, so a regex arrives as untrusted input and is compiled at validation time inside a guard that declines to validate that one field rather than taking down the form for every editor in the content type. Numeric constraints are structured rather than textual — integer, minimum, maximum — which lets the engine generate the message when the schema doesn't supply one.
The locale set is per-tenant, not a constant. One deployment serves several independent sites, each with its own language list — so nothing in the form layer may assume two locales, or which two. The list is fetched at runtime, cached in session storage against a default, and read from context by every locale-aware component; switching sites re-establishes the session and reloads. This is the constraint that makes the multi-locale contract above worth its complexity: the number of tabs is not known at build time, and a hardcoded pair is the kind of assumption that only fails after a new site is onboarded.
Incremental migration behind a reverse proxy, not a cutover. Both apps are served under separate path prefixes on one origin, sharing the same REST API and the same session, so modules moved one at a time with the legacy app still fully functional and editors moving between the two without noticing a boundary. The backend was deliberately not part of the migration — the React client speaks the API the Polymer client already spoke — which confined the risk to one tier and meant a module that misbehaved could be reverted by routing rather than by rollback. No long-lived migration branch, no feature freeze.
Also in this system
- Site tree: node lookup moved from string-path matching to numeric
pathIndices([0,2,1]→ direct index descent), and a 310-line monolithic component became five focused hooks — data, lookup, node form, operations, search highlighting — across six components. The rewrite also closed a latent bug where pasting a subtree produced duplicate endpoints, which string-matching lookup had made easy to write and hard to see. - Deferred loading at the drawer boundary: a content form can hold many complex fields and most are never opened, so the option lists they need are fetched when a drawer opens rather than on form mount — the difference between one request and a dozen on every edit. Reference fields additionally guard reloads with a cache key over the value already loaded, and check the list already in memory before issuing a request at all.
- API layer: fetch calls scattered across components were consolidated into a single typed namespace, so the client's view of the API is one file to read rather than a grep.
- Form identity via context:
FormIdContextgives every engine instance a stable id, which is what makes validation keyed by(formId, locale)addressable from outside the tree the form renders in.
Impact
- Editors never saw a freeze; the two apps ran side by side for the whole migration, and every module was revertible on its own.
- The CMS is off a dead framework and onto a stack the team can hire for and upgrade.
- New schema field types ship as a registry entry, with the renderer untouched.
- Multi-locale coordination is an explicit, inspectable data contract rather than components reaching into each other's DOM.
- Tree operations are index-descent rather than string matching, and the operation that used to produce duplicate endpoints no longer can.
