All articlesSK / BLOG

Migrating a Backbone.js app to React without a rewrite: Web Components as the bridge

How I moved a Backbone.js and Marionette product toward React micro-frontends one screen at a time, using Web Components as the boundary both worlds understand.

REACT · WEB COMPONENTS · FRONTEND ARCHITECTUREMigrating a Backbone.jsapp to React without arewrite: Web Components asthe bridgeSUMAN KUMAR KHAN · BLOG● REC
On this page
  1. Why not rewrite
  2. Web Components: a boundary both sides speak
  3. Migrating screen by screen
  4. Not just migration: fixing what was there
  5. What I'd keep doing

At Thynk Web I worked on App Studio, a product built on Backbone.js, Marionette and jQuery. I led its migration toward React micro-frontends. The constraint that shaped everything: the product had to keep working, and keep shipping features, the whole time. A big-bang rewrite was never on the table. This post is about the approach — and why Web Components turned out to be the right bridge.

Why not rewrite#

Rewrites look clean on a whiteboard and go badly in practice. The old app keeps changing while you rebuild it, behaviour that nobody documented gets lost, and for months you ship nothing users can see. The alternative is an incremental, strangler-style migration: new code grows around the old system, one piece at a time, until the old piece can be removed.

The hard question with incremental migration is the boundary. How does a Marionette view host a React component, pass it data and hear back from it, without either side knowing the other's internals?

Web Components: a boundary both sides speak#

A Custom Element is just an HTML tag. Backbone and Marionette already know how to render HTML and listen to DOM events. React knows how to render into any DOM node. So a custom element that mounts a React tree inside itself gives you a contract both worlds understand natively:

  • Inputs go in as attributes or properties.
  • Outputs come out as DOM CustomEvents.
  • Lifecycle is handled by the browser through connectedCallback and disconnectedCallback.
define-react-element.tsx
import { createRoot, type Root } from 'react-dom/client';
import type { ComponentType } from 'react';

export function defineReactElement<P extends object>(tag: string, Component: ComponentType<P>) {
  class ReactElement extends HTMLElement {
    private root?: Root;
    private props = {} as P;

    set data(value: P) {      // rich inputs as a property
      this.props = value;
      this.render();
    }
    connectedCallback() {
      this.root = createRoot(this);
      this.render();
    }
    disconnectedCallback() {
      this.root?.unmount();   // no leaked React trees when Marionette tears down
    }
    private render() {
      this.root?.render(<Component {...this.props} emit={this.emit} />);
    }
    private emit = (type: string, detail?: unknown) =>
      this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
  }
  if (!customElements.get(tag)) customElements.define(tag, ReactElement);
}

On the Backbone side, the new screen is just markup and an event listener:

stage-view.js
const StageView = Marionette.View.extend({
  template: () => '<app-stage-editor></app-stage-editor>',
  events: { 'stage:save app-stage-editor': 'onSave' },
  onRender() {
    this.$('app-stage-editor')[0].data = { stage: this.model.toJSON() };
  },
  onSave(e) {
    this.model.save(e.originalEvent.detail);
  },
});

The Marionette view doesn't know React exists. The React component doesn't know Backbone exists. That's the point.

Migrating screen by screen#

With the bridge in place, the order of work becomes a product decision instead of a technical one. Pick a screen, rebuild it in React behind a custom element, swap the tag in, and remove the old view once it's stable. Old and new code live side by side in the same build (we used Webpack) while the migration progresses.

A few rules kept this safe:

  1. Keep the model as the source of truth during the transition. The React component receives data and emits intentions; the existing model layer still saves.
  2. Make the contract small. One data property and a handful of named events per element. If a component needs ten inputs, the boundary is in the wrong place.
  3. Clean up on disconnect. Marionette re-renders and destroys views often. Unmounting the React root in disconnectedCallback prevents memory leaks and ghost listeners.

Not just migration: fixing what was there#

Migration work forces you to read the old code closely, and that surfaces bugs. On App Studio I resolved 90% of the API Builder bugs and did performance work on metadata and stage logic. I also shipped 5+ enhancements while the migration was ongoing — stage duplication, copy-paste, actions, rules and automation — which is the real argument for incremental migration: users kept getting improvements instead of waiting for a rewrite.

Components were built from Figma designs, and I documented the approach in Confluence so the rest of the team could build new screens the same way.

What I'd keep doing#

  • Choose the boundary before the framework. The custom-element contract mattered more than any React pattern inside it.
  • Ship value during the migration. Features and bug fixes alongside the rewrite keep the business on board.
  • Write it down. A migration is a team effort over months; a short, clear guide is what makes the tenth migrated screen look like the first.

Web Components are often pitched as a way to build design systems. Their quieter superpower is being the one interface every framework — including a long-lived Backbone app — already agrees on.

SHARELinkedInX