Strangler fig, branch by abstraction, vertical slicing: the strategy decides whether a frontend migration ships, more than the framework you move to. Here's how each one works, where each one bites, and how we combine them on real projects.
Teams that plan a frontend migration spend weeks on the target: React or Vue, Next.js or Vite, Redux or TanStack Query. Then they spend almost no time on the part that decides whether it ships: how the old app and the new app will live together until the old one is gone.
That part is the migration strategy. Pick the wrong one and a good framework choice still turns into an 18-month rewrite branch that never merges. Pick the right one and even a messy legacy app moves over a piece at a time, with users never noticing a launch day.
This post covers the strategies we actually use: the big-bang rewrite (as the baseline), the strangler fig, branch by abstraction, and vertical slicing, plus the supporting tactics that make them safe: parallel runs, feature flags, micro-frontends, anti-corruption layers and expand-contract. Then it shows how they fit together, because on a real project you rarely use just one. Our framework-specific guides (Angular, Ember, Backbone, jQuery, vanilla JS) go deeper on each starting point. This one is about the strategy underneath all of them.
The Short Version
| Strategy | Unit of change | What runs side by side | Rollback | Best for |
|---|---|---|---|---|
| Big-bang rewrite | The whole app | Nothing in production until launch day | Hard: the old app has gone stale | Small or frozen apps |
| Strangler fig | A route or a screen | Old and new apps behind one router | Point the route back at the old app | Apps with clear routes or pages |
| Branch by abstraction | A shared dependency behind a seam | Old and new implementations behind one interface | Flip the switch back | Services, stores and widgets used everywhere |
| Vertical slicing | One user-facing feature, top to bottom | Migrated and unmigrated features | Per slice | Choosing the order of work |
The first row is the one people start with. The other three are what actually works, and they answer different questions. Strangler fig decides where the line between old and new runs. Branch by abstraction handles the things that cross that line. Vertical slicing decides what to move next.
Big-Bang Rewrite: the Baseline
The big-bang rewrite is the default plan nobody writes down: start a new repo, rebuild everything, switch over on a chosen day. It's worth understanding properly, because every other strategy exists to avoid its failure modes.
When it is actually right
- The app is small. A few screens that one or two people can rebuild in a few weeks. The overhead of running two apps side by side costs more than the rewrite.
- The app is frozen. No new features are planned, so the old app won't move while you rebuild it.
- The old app can't host anything new. Some legacy stacks make it so painful to mount new code that coexistence costs more than starting clean. This is rarer than it seems. Almost anything can serve an iframe or hand off a route.
Why it usually fails
- The target keeps moving. The business doesn't stop for your rewrite. Every feature shipped in the old app is one more thing the new one has to catch up on, and the gap rarely closes.
- Nothing is proven until the end. Months of work with zero real users means every wrong assumption surfaces on launch day, all at once.
- The old app holds knowledge nobody wrote down. Ten years of bug fixes are hidden in it. Joel Spolsky called rewriting from scratch the single worst strategic mistake a software company can make, for exactly this reason.
- Rollback is fiction. By launch day the old app is months behind. "Roll back" means going back to software everyone has stopped maintaining.
The incremental strategies below all share one idea: get the new code in front of real users early, in small pieces, while the old app keeps running.
Strangler Fig
Martin Fowler named this pattern after a vine that grows around a host tree until the tree is gone and the vine stands on its own. In a frontend migration, the vine is the new app and the tree is the old one.
How it works
- Put something in front of both apps. A reverse proxy, a CDN rule, or a thin shell app that owns the router. Every request goes through it.
- Send every route to the old app at first. Nothing has changed for users, but the switch now exists.
- Move one route. Build it in the new app and flip that one path in the router.
- Repeat until the old app serves nothing. Then switch it off.
The routing layer can be as simple as a list of migrated paths:
// shell/router.ts: which app owns which path
const MIGRATED = ['/settings', '/profile/:username', '/article/:slug'];
export function ownerOf(path: string): 'react' | 'legacy' {
return MIGRATED.some((p) => matchPath(p, path)) ? 'react' : 'legacy';
}
In production this is often a CDN or proxy rule instead of code, and each route can sit behind a feature flag so you can roll it out to 5% of users first.
Route-level or widget-level
There are two grains to strangle at:
- Route-level. Whole pages move at once. It's the cleanest option: each page is owned by one framework, and the boundary is a URL. Start here if your app has real routes.
- Widget-level. New components are mounted inside old pages (a React date picker inside an Ember template, for example). This is how you migrate apps with one giant page, or a shared header that appears everywhere. It's more fragile, because the two frameworks now share a DOM.
Most projects use both: widget-level for the shared shell (nav, header, notifications), route-level for everything else.
Where it shines
- Real users see real progress within weeks. Every migrated route is in production.
- Rollback is one line. Point the route back at the old app.
- Feature work never stops. New features go straight into the new app.
- The end is visible. "34 of 52 routes migrated" is a progress bar a manager can read.
Where it costs you
- Two runtimes at once. Users may download both frameworks for a while. Keep the old bundle off migrated pages where you can.
-
Shared state across the boundary. Login, the current user, feature flags and unsaved drafts have to be readable by both apps. Put them somewhere neutral: a cookie,
localStorage, or the API. - CSS bleed. Global styles from one app leak into the other. Scope the new app's styles (CSS Modules, a prefix, or Shadow DOM) before you mount anything.
- Navigation between apps. Crossing from a React page to an Ember page is a full page load unless the shell handles it. Users notice this, so group related routes and move them together.
Gotchas we hit
- The router is the hard part, not the pages. Agree on how deep links, redirects, 404s and query strings behave across both apps before moving a single route.
- Auth needs to move first. If the new app can't read the old app's session, every migrated page looks logged out. A shared cookie is the usual fix.
-
Don't strangle by file. Moving "all the components in
/shared" isn't strangling, it's a horizontal layer (see vertical slicing below). Strangle by what users see. - Delete as you go. Once a route is moved and stable, remove its old code. Otherwise the old app never shrinks and you end up maintaining both forever.
The Angular guide walks through a strangler fig migration end to end, including the shell app and the staged rollout.
Branch by Abstraction
Strangler fig works when the thing you're replacing has a clear boundary, like a URL. But some parts of a frontend don't have one. The data layer, the auth service, the API client, the store, a date library: they're used from every page. You can't route around them. Branch by abstraction is the pattern for those.
The name comes from Paul Hammant and is described by Fowler and trunkbaseddevelopment.com. The idea: instead of making a long-lived version-control branch to replace something, you make the "branch" in the code itself, behind an interface.
How it works
- Add a seam. Write an interface that describes what callers need. Make the old implementation fit it.
- Move callers onto the seam. One at a time, change code that talks to the old thing directly so it talks to the interface instead. Ship each change. Nothing behaves differently yet.
- Build the new implementation behind the same interface, in the main branch, switched off.
- Switch over. A flag picks the new implementation, first for internal users, then for everyone.
- Delete the old implementation, and the flag. If the seam isn't useful anymore, delete it too.
Here's what that looks like for a data layer. Say pages load articles through an Ember Data store or Backbone models, and you're replacing that with a plain API client and TanStack Query. The seam is a small repository interface:
// articles/repository.ts: the seam
export interface ArticleRepository {
get(slug: string): Promise<Article>;
list(filter: ArticleFilter): Promise<Article[]>;
save(input: ArticleInput): Promise<Article>;
}
// old: wraps the legacy store, converting its records to plain objects
export const legacyArticles: ArticleRepository = {
get: async (slug) => toArticle(await store.queryRecord('article', { slug })),
list: async (filter) => (await store.query('article', filter)).map(toArticle),
save: async (input) => toArticle(await store.createRecord('article', input).save()),
};
// new: talks to the API directly
export const apiArticles: ArticleRepository = {
get: (slug) => api.get(`/articles/${slug}`).then((r) => r.article),
list: (filter) => api.get('/articles', filter).then((r) => r.articles),
save: (input) => api.post('/articles', { article: input }).then((r) => r.article),
};
export const articles: ArticleRepository =
flags.isOn('articles-api') ? apiArticles : legacyArticles;
Two details make this work. First, the interface speaks plain objects, not framework records. If callers receive Ember Data models, they'll keep calling .get('title') and you haven't built a seam, just a wrapper. Second, both implementations live in the main branch and are tested in CI. The "branch" is a flag, not a Git branch.
Where you'll use it in a frontend
- The data layer. Ember Data, Backbone collections, Angular services with their own caches → a query library. This is the most common use.
-
Auth and session. One
sessioninterface that both the old and new apps call, so login works on both sides of the strangler boundary. - A shared component. The date picker, the rich text editor, the data grid. Wrap it in one component API, then swap the implementation underneath.
- A library replacement that isn't a framework change at all: moment.js → date-fns, a charting library, an HTTP client.
Why not just use a long-lived branch?
Because a migration branch that lives for months is a big-bang rewrite in disguise. It drifts from main every day, the merge at the end is huge, and nothing in it has met a real user. Branch by abstraction keeps every step small and merged. It pairs naturally with trunk-based development and with feature toggles.
The cost: extra code for a while (the interface, two implementations and a flag), and the discipline to actually do step five. Seams and flags that outlive the migration become their own kind of legacy.
Vertical Slicing
Strangler fig and branch by abstraction tell you how to keep old and new running together. Vertical slicing tells you what to move next.
A vertical slice is one user-facing feature moved top to bottom: its UI, its state, its data access, its routes and its tests, all together, until a user can do that thing entirely in the new code. The opposite is a horizontal layer: "first we'll port all the models, then all the services, then all the components".
Why horizontal layers fail
- Nothing ships until the last layer is done. Porting every model first gives you months of code with no user, which is the big-bang problem again.
- You guess at requirements. Without a real screen using it, a ported service is shaped by what the old code did, not what the new UI needs.
- Progress is invisible. "80% of the services are done" means nothing to the people paying for it.
Choosing the first slice
The first slice is mostly about proving the plumbing: routing between apps, shared auth, the build and deploy, the test setup. So pick one that is:
- Real, but low-risk. A settings page or a profile page, not checkout.
- Light on shared dependencies. It shouldn't need half the app's services to render.
- Easy to compare. It has clear behavior you can test old against new.
- Used often enough that you'll hear quickly if something's wrong.
After the first one, order the rest by value against coupling. Move high-value, loosely coupled features early. Leave the tangled core, the screen that touches everything, until the seams around it already exist.
How slices map onto the other strategies
In a well-run migration, a vertical slice usually is a strangler fig step: a feature is a set of routes, and moving the feature means flipping those routes. When a slice hits a dependency it shares with unmigrated features (the article store used by both the feed and the editor), that's where a branch-by-abstraction seam goes in.
Supporting Tactics
These aren't migration strategies on their own. They're tools that make the three above safe.
Parallel run and dark launching
Run the new code alongside the old one in production, compare the results, but only show the old one. GitHub's Scientist library made this famous for backend code. In a frontend it's useful for a new data layer or a new calculation (pricing, permissions, validation): call both implementations behind the seam, log any differences, return the old result. Once the differences hit zero, switch. Dark launching is the same idea for whole features: ship them to production hidden, and turn them on later.
// compare old and new behind the seam; users still get the old result
async function get(slug: string) {
const [oldResult, newResult] = await Promise.allSettled([
legacyArticles.get(slug),
apiArticles.get(slug),
]);
reportDiff('articles.get', oldResult, newResult);
if (oldResult.status === 'rejected') throw oldResult.reason;
return oldResult.value;
}
Watch the cost: a parallel run doubles API calls. Sample a percentage of traffic, and never run both sides of a write.
Feature flags and canary releases
Every switch in this post (a migrated route, a new implementation behind a seam) should be a flag you can flip without a deploy. That lets you release to staff first, then 5% of users, then everyone, and roll back in seconds. Name each flag after the migration step and give it an expiry date. The feature toggles article on Fowler's site is the best guide to keeping them under control.
Micro-frontends as a way to coexist
Micro-frontends (module federation, single-spa, web components) let several apps share one page. They're a useful vehicle for widget-level strangling: the old app becomes one micro-frontend, the new one another. But think twice before making them the destination. If the goal is one React app, use the micro-frontend setup as scaffolding and take it down when the migration ends. Otherwise you've swapped one legacy app for a distributed system.
Anti-corruption layer
An anti-corruption layer translates between the old model and the new one so the old model's quirks don't leak into new code. The toArticle() function in the branch-by-abstraction example is a tiny one: it turns a legacy record (with its computed properties, observers and odd field names) into a plain object the new code can trust. Keep these at the boundary and delete them when the old side is gone.
Expand and contract
Also called parallel change. When old and new code share data (an API response, a localStorage key, a URL format, a cookie), you can't change its shape in one go. Expand first: support both the old and new shapes. Migrate each reader and writer. Contract: remove the old shape once nothing uses it. It's how you rename a query parameter or restructure saved user preferences without breaking the half of the app that hasn't moved yet.
Combining Them: the Playbook We Use
On a real project these aren't alternatives. They are layers of one plan:
| Question | Answered by |
|---|---|
| Where does the line between old and new run? | Strangler fig (routes, then widgets) |
| What do we move next? | Vertical slicing (value against coupling) |
| What about things every page depends on? | Branch by abstraction (a seam, two implementations, a flag) |
| How do we know the new one behaves the same? | Parallel run and shared tests |
| How do we release and roll back? | Feature flags and canaries |
| How do we change shared data safely? | Expand and contract |
In phases:
- Foundation. Put the shell or proxy in front of the old app, with every route still pointing at it. Make auth work on both sides. Set up one test suite that can run against either app. Add seams around the data layer and other shared services. Nothing changes for users.
- First slice. Move one low-risk feature end to end. Its real job is to prove the plumbing: routing, auth, deploys, monitoring, rollback.
- Steady state. Move slices in order of value against coupling. Each slice is a set of routes flipped behind a flag. Switch seams to their new implementations as soon as their callers allow it, with a parallel run where correctness matters. Ship new features only in the new app.
- Removal. When the old app serves no routes, switch it off. Then delete the seams you no longer need, the anti-corruption layers and every migration flag. A migration isn't done until the scaffolding is gone.
Choosing a Strategy
If you're deciding where to start, these questions settle most of it:
| If your app… | Lean towards |
|---|---|
| Is a handful of screens, or is frozen | Big-bang rewrite. Coexistence isn't worth it. |
| Has clear routes and pages | Strangler fig at route level |
| Is one giant page, or has a shared shell everywhere | Strangler fig at widget level, often with a micro-frontend host |
| Has a data layer, store or service every page uses | Branch by abstraction around it, early |
| Has few tests | Write tests at the user level (end-to-end) first, then any strategy. Without them, nothing tells you a slice is done. |
| Has many teams shipping in parallel | Vertical slices owned by one team each, behind flags |
| Must keep shipping features during the migration | Anything except big-bang |
Measuring Progress and Knowing When You're Done
Incremental migrations have a known failure mode: they stall at 80%. The easy routes are done, the hard core remains, and the team gets pulled onto feature work. You end up running two frameworks forever. Measuring progress openly is the best defense:
- Routes served by the new app, as a percentage of traffic, not just a count. Ten rarely used admin pages don't equal one home feed.
- Lines of legacy code left, falling every week. If it stops falling, something is wrong.
- Seams and flags still open. Each one is temporary and should have an owner and an end date.
- Parity results. For each slice: do the same tests pass against old and new, and does the parallel run show zero differences?
And a short definition of done that every slice has to meet before its old code is deleted: same tests passing on both, flag at 100% for a full release cycle with no rollbacks, no remaining imports of the old code, and the old code removed in the same sprint.
Big-bang rewrites fail on a deadline. Incremental migrations fail on discipline. Pick the failure you can manage.
Bottom line:
- Get new code in front of real users early. Every strategy that works is built on this.
- Strangle by what users see, route by route, not file by file.
- Put a seam around anything every page depends on, and switch it with a flag, not a merge.
- Move whole features, not layers. A finished slice ships. A finished layer doesn't.
- Delete as you go, including the scaffolding. The migration ends when the seams and flags are gone, not when the last route moves.
Further in This Series
Each of these takes the strategies above and applies them to one starting point or one design decision:
- Moving from Angular to React: A Practical Guide, with the strangler fig worked end to end.
- Leaving Ember for React: What Your Conventions Were Hiding, where Ember Data is a classic branch-by-abstraction candidate.
- The Framework Nobody Wrote Down: Taking a Backbone App to React
- Who Updates the Total? Taking a jQuery App to React, mostly widget-level strangling.
- The State Is in the DOM: Taking a Vanilla JavaScript App to React
- Client Patterns: MVC, MVVM, and What Came After
- One-Way vs Two-Way Binding
- Client-Side vs Server-Side Rendering
Our Migration Plugins
Choosing a strategy starts with knowing the app: every route, every shared service, every piece of hidden wiring. That map is slow to build by hand. We've packaged how we do it as free, open-source plugins for Claude Code, one per starting point. Each one plans an incremental, strangler-fig migration by default:
| Plugin | Starts from |
|---|---|
ember-to-react |
Ember Classic and Octane |
angular-to-react |
Angular 2+ and AngularJS 1.x, including hybrid ngUpgrade apps |
backbone-to-react |
Backbone.js, Marionette, jQuery templates |
jquery-to-react |
jQuery apps, plugins and jQuery UI |
vanillajs-to-react |
Plain HTML, CSS and JavaScript |
Each plugin comes with a migration skill, a /<plugin>:plan command that writes a migration plan (strategy, units, route map, test plan and risks) without touching your code, a read-only inventory agent that maps the old app, a parity-reviewer agent that compares each migrated piece with its original, and a bundled Context7 server for up-to-date library docs. To install one in Claude Code:
/plugin marketplace add cobuild-tech/cbx-plugins
/plugin install ember-to-react@cbx-plugins
They also work in Cursor, in VS Code with GitHub Copilot, and in the GitHub Copilot CLI. Progress is kept in a .migration/ folder in your repo, so a migration can carry on across sessions and teammates. The source and setup steps are at cobuild-tech/cbx-plugins, and issues are welcome.
About MigrateX
MigrateX is our platform and service for moving software, data and infrastructure from one technology to another. It combines automation with experienced engineers. The automation does the repetitive work quickly, and the engineers make the tricky decisions, like where the strangler boundary runs and which shared services need a seam first.
A MigrateX project usually runs like this:
- Assessment. We map every route, shared service and hidden dependency in your app, so you see where the work is before anyone agrees on a timeline.
- Plan. With your team, we choose the strategy, the slice order, and what can simply be deleted.
- Migration, step by step. Automation handles the repetitive parts. Our engineers handle the router, the seams and the code that connects old and new.
- Proof that it works. The same tests run against both versions. A piece only counts as moved when it passes on both.
- Handover. We remove the temporary code, the seams and the flags, and leave your team with a codebase they understand and own.
Your app keeps running and shipping features the whole time, so there's no big launch day. Our migratex-examples repo has small apps in each legacy framework next to their migrated versions.
Planning a migration and not sure which strategy fits? Tell us a little about your app through Contact Us, and we'll start with a free chat about where the work is likely to be.
References
Further reading
- Martin Fowler. Strangler Fig Application. https://martinfowler.com/bliki/StranglerFigApplication.html
- Martin Fowler. Branch By Abstraction. https://martinfowler.com/bliki/BranchByAbstraction.html
- Paul Hammant. Branch by Abstraction. https://www.branchbyabstraction.com/
- Trunk Based Development. Branch by Abstraction. https://trunkbaseddevelopment.com/branch-by-abstraction/
- Danilo Sato. Parallel Change. https://martinfowler.com/bliki/ParallelChange.html
- Ian Cartwright, Rob Horn and James Lewis. Patterns of Legacy Displacement. https://martinfowler.com/articles/patterns-legacy-displacement/
- Pete Hodgson. Feature Toggles (aka Feature Flags). https://martinfowler.com/articles/feature-toggles.html
- Cam Jackson. Micro Frontends. https://martinfowler.com/articles/micro-frontends.html
- Microsoft. Strangler Fig pattern. https://learn.microsoft.com/en-us/azure/architecture/patterns/strangler-fig
- Microsoft. Anti-corruption Layer pattern. https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer
- GitHub. Scientist: a library for carefully refactoring critical paths. https://github.com/github/scientist
- Joel Spolsky. Things You Should Never Do, Part I. https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/
- Sam Newman. Monolith to Microservices. O'Reilly, 2019. https://samnewman.io/books/monolith-to-microservices/
Source code
- CobuildX AI. cbx-plugins: Claude Code plugins for Backbone, Angular, Ember, jQuery and Vanilla JS to React migrations. https://github.com/cobuild-tech/cbx-plugins
- CobuildX AI. migratex-examples: small jQuery, Backbone, Angular, Ember and Vanilla JS apps and their migrated versions. https://github.com/cobuild-tech/migratex-examples
Originally published on the CobuildX blog.





Top comments (0)