Sergi Jajanidze

Senior Frontend & Full-Stack JavaScript Engineer

Internal multi-locale content management system

Migrating a production CMS from Polymer 3 to React + TypeScript

Role
Lead engineer — engine owner, delegated field layer
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 · 22 schema-driven field types · 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. I led its migration to React + TypeScript. 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)          │             22 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 first

Decisions 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 Ant Design without a designer in the loop. (The same argument runs the other way on the public banking 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 22 schema types to components, plus a config-driven prop bundle that lets one implementation serve several types. Twenty components cover twenty-two 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 actually pays: the field-definition contract, the registry, and the props flowing between engine and field are all strictly typed, while the value remains any 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 became an explicit contract instead of DOM traversal. Every content record exists in parallel per locale, edited via tabs. Simple fields are locale-scoped, but complex ones (arrays, dictionaries, references) 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 (this.elements) and mutate them directly, which has no React equivalent; reproducing it with refs would have been fighting the framework. I made the parent own 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 → locale

Unidirectional 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 needed exactly one escape hatch, and I'd rather name it than hide it. Nested fields are forms inside forms, so the engine renders a component that renders the engine — a genuine module cycle that ES imports resolve to undefined depending on which side loads first. It's broken with a synchronous require() at render time in the nested field. That is a workaround, not a design: the clean fix is for the engine to hand its renderer down through the registry or context so the child never imports its parent. It's one line, it's commented as what it is, and it's the first thing I'd point at in a review.

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 on saveAll() blocks 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” only appears once more than one form is actually 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 guard covers a malformed pattern — it warns and declines to validate that one field rather than taking down the form for every editor in that content type — but it does not cover catastrophic backtracking, which doesn't throw, it hangs the tab. Bounding that properly needs a pattern allowlist or an engine that can be timed out, and it's a known gap rather than a solved problem.

The locale set is per-tenant, not a constant. One deployment serves several independent sites, and each has 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 would have been 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, not by rollback. No long-lived migration branch, no feature freeze.

Other notable work

  • Site tree: rewrote node lookup from string-path matching to numeric pathIndices ([0,2,1] → direct index descent), and split a 310-line monolithic component into four focused hooks — data, lookup, operations, search highlighting. Fixed a latent bug where pasting a subtree produced duplicate endpoints, which the 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, which removed a class of redundant round-trips and the flicker that came with them.
  • API layer: consolidated fetch calls scattered across components into a single typed namespace, so the client's view of the API is one file to read rather than a grep.

Splitting the work

I owned the engine: the field-definition contract, the registry, the form API, and the change, validation and save handlers — the surface where one wrong decision propagates into every content type at once. Once that was stable and typed, most individual field components were markup against a fixed prop contract, so those went to team members. That split is the practical payoff of the registry design rather than a coincidence of staffing: a field type is a self-contained component plus a row in a map, reviewable on its own, unable to reach into the engine, and safe to hand to someone who doesn't need the multi-locale contract in their head to build a text input.

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 now an explicit, inspectable data contract rather than components reaching into each other's DOM.

What I'd change

FormState is the deliberate compromise: a module-level singleton is global mutable state, and it exists because arbitrarily nested drawers render through portals, where neither prop drilling nor context expresses cross-tree coordination cleanly. Given that constraint I'd make the same call again — the alternative was threading save and validation handles through every intermediate component, which couples far more code to the drawer structure. What I'd fix is that registration is lifecycle-sensitive: a form that unmounts without unregistering leaves a stale entry, and commit ordering is only as good as the order forms happened to register in. A scoped store with real instance boundaries, where the form tree owns the graph rather than a counter, gets the same coordination with neither weakness.

The engine's pure transformations — locale transposition above all — are where a regression would be silent and data-shaped rather than visible: a form that renders wrong is reported within the hour, while a transposition that drops the third locale of a long array is found weeks later by a reader of the published site. That asymmetry is the argument for putting coverage there first, ahead of the component layer where failures announce themselves, and it's where I'd spend the next block of time on this codebase.