Internal multi-locale content management system
Polymer to React/TS CMS migration
Migrated a daily-use editorial CMS off a dead framework, running old and new apps side by side behind one reverse proxy so editors never saw a freeze. The real work was rewriting a form engine that generates the whole editing UI at runtime from user-defined content types — including multi-locale editing that Polymer had solved with DOM traversal.
The problem
An internal CMS, 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. It has no hand-written forms: content types are user-defined schemas fetched at runtime, and the whole editing UI is generated from them.
So the migration was really a rewrite of that form engine, shaped by three facts: the schema is data, not code; every record exists in parallel per locale; and fields nest arbitrarily deep.
The architecture
server-owned schema ──▶ FormEngine ──▶ FIELD_COMPONENT_MAP ──▶ components
(per content type) │ schema 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 firstKey decisions
React over Lit 3
Lit 3 was the natural successor to Polymer and a real contender. It lost on one argument: this internal tool had no designer. React's mature component libraries supplied trees, drawers, tables and date pickers out of the box, while the web-component route meant building that widget layer before writing a line of the engine. On the public site, where a custom-element design system already existed, the same reasoning made Lit the right answer.
A declarative field registry
Polymer built fields imperatively with document.createElement and appended them to the DOM. The React port keeps the registry but makes it declarative: FIELD_COMPONENT_MAP maps each schema type to a component, plus a config-driven attrs bundle that lets one component serve several types. Rich text and its cut-down variant are one editor; embedded objects and stored references are one picker in two modes. A new field type is a new row in the map — the renderer never changes.
Unknown field types render a placeholder
Schemas are user-authored and ship independently of the client, so a schema can legitimately reference a type this build doesn't know. The renderer warns, shows a visible marker in place of the field and keeps the rest of the form editable. Throwing would let one unknown row break a whole content type — for an editor mid-draft, not the engineer who caused it.
Types stop at the schema boundary
A field's value 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 build to editorial data. So the field-definition contract, the registry and the props between engine and field are strictly typed, and the value is explicitly untyped. Being clear about where types stop keeps the rest honest, instead of scattering casts through components that pretend to know more than they do.
Multi-locale editing as an explicit data contract
Every record exists per locale, edited in tabs. Simple fields are per-locale, but arrays, dictionaries, nested groups, references, choice fields and raw JSON are edited across all locales at once — add one array row and every locale gets a matching entry. Polymer did this by having fields find and mutate their siblings through the DOM, which has no React equivalent.
Here the form owns {locale: values} for every tab, complex fields receive multiLangValues and an onMultiLangChange callback, and data is transposed at the drawer boundary:
storage: {en: [{...}], de: [{...}]} // locale → rows
editing: [{en: {...}, de: {...}}] // row → localeThe transposition is a pure, testable function, and it has to handle imperfect data. Locales drift out of sync in practice, so it pads short locales to the longest array and drops rows that are empty in every locale on the way back out. Content that predates a newly added locale is the normal case, not the exception.
The locale set is per-tenant
One deployment serves several sites with different language lists, so nothing in the form layer may assume two locales — or which two. The list is fetched at runtime, cached in session storage and read from context; switching sites reloads the session. A hardcoded pair is the kind of assumption that only fails after a new site is onboarded.
Forms inside forms, saved as one
Nested fields render the engine inside the engine — a real module cycle. The nested field resolves the engine at render time instead of through a top-level import, so neither side ever sees the other as undefined. It is one line, with a comment saying why.
Nested drawers also mean several forms, each with locale tabs, can be open at once, and one “save” has to commit all of them. A FormState singleton tracks validation by (formId, locale) and holds each form's save function. saveAll() refuses to start if any locale of any form is invalid, then saves in reverse registration order so children persist before their parents. Form ids come from FormIdContext rather than props, and “Save All” only appears once more than one form is registered.
Validation per field type
“Required” means a non-empty string for text, a populated link for a URL and a populated url for an upload — three shapes behind one schema flag. Patterns and numeric bounds are authored in the schema, so a regex is untrusted input: it is compiled inside a guard that skips that one field rather than crashing the form. Numeric constraints are structured (integer, minimum, maximum), so the engine can generate an error message when the schema doesn't supply one.
Incremental migration behind a reverse proxy
Both apps ran under separate path prefixes on one origin, sharing the REST API and the session. Modules moved one at a time while the legacy app stayed fully functional, and editors moved between the two without noticing a boundary. The backend was deliberately left alone — the React client speaks the API the Polymer client spoke — so risk stayed in one tier, and a misbehaving module was reverted by routing rather than by rollback. No long-lived branch, no 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 large monolithic component became five focused hooks. The rewrite also closed a bug where pasting a subtree produced duplicate endpoints. - Deferred loading: option lists for complex fields are fetched when a drawer opens, not on form mount — one request instead of a dozen per edit — and reference fields skip reloading values already in memory.
- API layer: fetch calls scattered across components were consolidated into one typed namespace, so the client's view of the API is one file.
Impact
- Adopted across five product teams.
- Editors never saw a freeze: both apps ran side by side, and every module could be reverted on its own.
- The CMS runs on a stack the team can hire for and upgrade.
- New field types ship as a registry entry, with the renderer untouched.