Architecture
One route at a time: 26 screens migrated, no product freeze
A simple strategy built on routes, feature flags and observability to gradually replace a no-code application with React.
Complex problems don’t always demand complex solutions.
When we decided to replace our no-code application with React, we could have set up a large rewrite project, frozen new feature development and worked for months before putting anything in production.
We chose another path: migrating one route at a time.
The strategy was neither disruptive nor particularly sophisticated. It combined a few well-known patterns, components we already used and a golden rule to keep the legacy system from growing.
The result was a gradual migration of 26 screens, organized into 12 main flows, while the product kept evolving.
When the shortcut starts charging for maintenance
Early in a company’s life, speed is a competitive advantage.
POCs, MVPs and no-code tools let you validate ideas quickly, learn from your first users and build complete experiences without the upfront cost of a traditional structure.
At Barte, a no-code application played that role well. It let us build the dashboard our users worked in, integrate services and adapt the product as we came to understand the market better.
The problem showed up with growth.
Maintenance came to depend on a handful of people. Without consistent standards, some parts became hard to understand and change. A change in one place could produce unexpected effects in another, while automated test coverage was limited and hard to expand.
Load times also went up, and certain integrations required solutions very specific to the platform. Little by little, the speed that had justified the original choice started to be lost in maintenance.
Migrating to React became less a technology decision and more a decision about autonomy. It’s the same reasoning that became an explicit principle in Barte’s engineering: no no-code/low-code in the core.
The challenge wasn’t rewriting the screens
Visually, many pages looked simple. The real problem was in the business rules accumulated over the years.
Some of them were documented. Others existed only in conditionals, data transformations and interface behaviors. In some cases, nobody was left with all the context needed to explain why a given rule existed.
Migrating meant reconstructing that knowledge without changing what the user perceived.
We also couldn’t develop the entire new application before putting it in production. Beyond slowing down learning, that would create one big switchover moment in which every risk showed up at once.
We needed to keep both experiences running in parallel, migrate gradually and go back quickly if something went wrong.
First, a POC that took a few hours
The strategy followed a pattern known as the Strangler Fig Pattern: instead of replacing the whole application at once, new parts would be built around the legacy system until it could be shut down completely.
To validate that architecture, we built a POC with two origins behind CloudFront: the existing no-code application and the new React application.
In CloudFront, each Behavior associates a route pattern with an origin. The default Behavior kept sending requests to the legacy system, while more specific Behaviors routed certain paths to React. This technique, known as path-based routing, let us migrate a family of pages without interfering with the rest of the application.
The anatomy of a migrated route
This configuration is worth opening up, because it is the unit we ended up repeating for every flow.
The React application wasn’t just another origin. It is a CloudFront distribution, registered as an origin of the main distribution. The public domain kept pointing at the same distribution as always, and that distribution started delegating route prefixes to the React one.
flowchart TD U["user"] --> D1["main distribution<br/>(the public domain)"] D1 -->|"Default (*)"| LEG["no-code application"] D1 -->|"/assets/* · /payfac · /payfac/*"| D2["React distribution"] D2 --> B["bucket with the<br/>React application build"]
This kept the two applications independent. The React one carried on with its own deploy pipeline, its own cache and its own origin, while the main one only had to know where to send each path.
Putting a route live meant creating Behaviors on the main distribution, respecting the precedence order:
0 /assets/* → React distribution (once: the SPA bundle)1 /payfac → React distribution ┐ one pair2 /payfac/* → React distribution ┘ per route ...9 Default (*) → no-code applicationTwo details in this configuration took a while to become obvious.
The first is the pair of Behaviors per route. /payfac/* does not match a bare /payfac,
because the slash is literal. Without both, the entry URL with no trailing slash fell
through to the default Behavior and ended up in the legacy system. A /payfac* would
solve it with a single Behavior, but it would also match any path starting with those
letters.
The second is caching disabled on all of them, and that one we learned the hard way. Caching is the lower distribution’s job. With caching on the upper one too, the same object exists in two layers with independent TTLs: a new deploy invalidated the React distribution, but the main one kept serving the previous version until its own TTL expired.
The result is the most thankless kind of bug to debug. Users on different versions of the same screen, nothing wrong with the deploy, and an invalidation that seemed to have no effect because it was being run on the wrong distribution. Layered caching only helps when each layer has a clear owner.
One detail was still missing for the SPA to work. A deep link like /payfac/rates doesn’t
exist as a file in the bucket, and hitting refresh on that route returned a 404. A
CloudFront Function solves this at the edge, serving the index for any path that doesn’t
look like a file:
function handler(event) { var request = event.request; var uri = request.uri;
// SPA deep link: no extension in the last segment, serve the index // and let the React router resolve it on the client if (uri.startsWith('/payfac') && !uri.split('/').pop().includes('.')) { request.uri = '/index.html'; }
return request;}CloudFront determined which application would receive the request. After that, the applications themselves checked a feature flag to decide whether that user would take part in the new experience.
If the flag was disabled, React redirected the user to the corresponding route in the legacy system. The previous application did the opposite: when it found an enabled feature, it forwarded navigation to React.
Both applications answered on exactly the same domain and the same subdomain. That choice allowed the new application to reuse the authentication session already established by the previous experience.
For the user, the transition was transparent: they could navigate between pages served by different technologies without authenticating again and without noticing the switch between applications.
In a few hours, the POC validated the riskiest assumptions:
- routing between the applications;
- reusing authentication;
- transparent navigation;
- rollback by simply turning off a flag.
We started with the fees screen
After the POC, we chose the fee configuration screen as the first real experience.
It wasn’t the easiest screen. Quite the opposite: it was one of the most complex and one of the biggest sources of problems in the legacy system.
The configuration had to represent different payment methods, channels, settlement periods, limits and payout options. Cards could have different rules per number of installments, reaching 24 configurations. There were also variations by plan, company context, MCC and fee structure version.
Choosing that screen mattered because it put the architecture to the test under real conditions. A simple page could validate the routing, but it wouldn’t show whether we could extract business rules, write tests and sustain the new structure.
That was also the moment we defined how the React application would be organized. We created a modular structure in which each product area could evolve independently inside the same application.
During the rebuild, we didn’t limit ourselves to reproducing the previous interface. We kept the necessary behavior, but took the chance to fix experience debt, improve filters and build the components on top of Barte’s design system.
The migration stopped being a simple technology translation. It became an opportunity to give consistency back to the product.
Our golden rule
Throughout the project, we followed one simple rule:
Nothing new would be built in the legacy system.
Critical bugs were still fixed immediately. We wouldn’t leave a user with problems just because that experience would be replaced in the future.
But new features were developed directly in React.
That also meant the migration wouldn’t interrupt the roadmap. Requests for new features, improvements asked for by users and other product priorities kept arriving normally.
In some cases, a new demand pulled the migration of a given experience forward. Instead of extending the legacy system only to rewrite it later, we brought that flow into React and built the new thing already in its final structure.
The rule kept the target from moving away while we tried to reach it.
A simple, repeatable strategy
After the first screen, the process settled into practically the same cycle:
flowchart TD A[pick an experience] --> B[extract its rules] B --> C[build it in React] C --> D[point the route] D --> E[release behind a feature flag] E --> F[observe] F --> G[widen the rollout] G --> H[remove the legacy code] H --> A
Each screen had a success criterion based on its main action. On a configuration page, for example, knowing that it loaded wasn’t enough: we needed to track whether the user started, advanced and completed the operation.
We instrumented those points in PostHog and created specific dashboards to follow each rollout. That let us detect drop-offs, errors and unexpected behavior before the problem turned into a larger volume of support tickets.
The migrated components also got unit tests, especially in the areas where business rules were densest. Tests didn’t replace observation in production, but they reduced the risk of reproducing a behavior in one scenario and breaking it in another.
Releases followed a progressive delivery strategy. We started with a selected group of users, moved to roughly 10% of the base and only then went to the full rollout.
When necessary, rollback was just a change to the feature flag. With the flag disabled, the route went back to the previous experience without requiring a new deploy.
Once we reached 100%, we removed the old implementation and kept the redirect to the new application. With each cycle, the legacy system got a little smaller.
We repeated that process until the migration was complete. Because the same mechanism applied to every experience, there was no big switchover and no moment when the entire user base had to take on the risk of a completely new application.
There was only a sequence of small, reversible changes.
Simple can work
The main lesson from this migration wasn’t about React or CloudFront.
It was about reducing the size of decisions.
We didn’t build a sophisticated migration platform. We used two origins, route rules, feature flags, tests and observability. Each element had a simple, well-understood responsibility.
The Strangler Fig Pattern gave direction to the architecture. Path-based routing separated the experiences. Feature flags controlled the exposure of the new application. Progressive delivery limited the impact of mistakes. The golden rule kept the legacy system from growing.
Taken separately, none of these ideas is new. The value was in combining them in a way the team could repeat.
Sometimes the best strategy to replace a complex system isn’t designing an equally complex solution.
It’s migrating one route, observing and repeating.