How to Migrate Legacy Apps to React
A practical, page-by-page strategy for migrating a legacy multi-page app to React, without pausing feature work for a risky big-bang rewrite.
Your legacy app is probably a multi-page app (MPA): multiple pages each rendered by the server and shipped as-is to the web browser. Upon clicking a link, the whole page is dumped and an entirely new page is re-created. These MPAs can get expensive quickly when combined with cloud services. That’s a lot of page rendering happening on your rented server, and most of it could be off-loaded to your customer’s web browser for free.
Old tech stacks also have real financial impact beyond just finding talent and the perception of the company as outdated. The question is how to get there from here.
The Lay of the Land
We’re usually looking at hundreds of template files ending in .cshtml, .blade.php, .jsp and similar. Templates often contain both javascript and the backend language intermingled. There is no API or only a partial API, and it might not serve JSON but instead XML, SOAP, or even partial HTML. There is no clear distinction between backend, which is the algorithmic, data-manipulating workhorse, and frontend, which is the visual and interactive face. Cleanly separating code into one of these two camps will be the primary guiding principle, as modern development emphasizes the different skills needed to succeed in each.
Getting to the ideal end state should prioritize security and reliability over performance. Refactoring working code should strive to rely on patterns that are, if not necessarily easy to understand at first blush, at least easy to prove correct once understood. Codebases undergoing migrations frequently utilize half-measures that wouldn’t be acceptable in a greenfield project. Let’s use an example.
Made to Order
The page Order.cshtml displays a customer’s order, which includes name, address, and listing all the line items each having descriptions, prices, and so on. We will alter the produced page so instead of creating nicely formatted HTML with all pieces in place, we will place just two HTML tags. One tag is an empty div with the id “order-page”. The other tag will only hold the raw data as computer-readable JSON. The result after the server “renders” the new page would look something like this.
<div id="order-page">
</div>
<div id="order-api-result">
{
"name": "John Doe",
"address1": "123 Sunny Lane",
"items": [
{ "description": "7-bearing Ball-bearing ring for kids bicycle crankshaft", "price": 10.99 },
{ "description": "Kids bicycle Handlebar covers with tassels, pink/purple", "price": 12.99 },
],
}
</div>
The way React works is to pick up a particular element, say, that div with id “order-page”, and fill it in using Components that render HTML. Typically after initialization, React will fetch the particular order’s data from the API — an API that doesn’t exist yet and probably won’t exist for months. So instead, we perform this half-measure of writing the data that the API would have returned into the initial document, which the server was already doing but this time without the colors and buttons and margins etc.
The entirety of the rest of the page that we cut out we move to a new file, Order.tsx. We surround it with React-ese like so, creating a Component out of it.
export function Order() {
const [data, isLoading, error] = useOrderApi();
return (
<div>
{/* pasted 400+ lines of original HTML */}
</div>
);
}
From here, we do three things. First, any interactivity like button clicks we wire up here in React. Second, we implement that useOrderApi hook in its own file. From the component’s point of view, the hook’s job is to fetch an order object (already parsed from JSON) and any extra bits of information about its arrival. Of course, that hook can’t actually use fetch right now, but the component needn’t know that. Instead, the hook fishes the order’s details from the other div in the document.
export function useOrderApi() {
// TODO need real API endpoint
const json = document.getElementById("order-api-result")?.textContent;
const orderObject = JSON.parse(json || null);
const [data, setData] = useState<IOrder>(orderObject);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(orderObject ? "" : "Could not fetch order");
return [data, isLoading, error] as const;
}
The final piece of this refactor goes at the top of the app. Simply inform React to connect that div to that component the same way an entire <App /> would be.
const divForOrderPage = document.getElementById('order-page');
if (divForOrderPage) createRoot(divForOrderPage).render(<Order />);
Costs and Benefits
The above refactor is a middle ground between an old MPA and a modern SPA. Clicking a link still dumps the whole page and reloads another, but the rendering of that page happens in the browser courtesy of React instead of on your AWS bill. The visual and interactive elements of the page are no longer performed by the backend. Indeed, the React code above can be placed into a separate repo, further modularizing the app and enabling modularization of the dev team.
Viewed from a pessimistic angle, the above performs slower than the original. Every page navigation means the browser must reload and reboot React. Granted, the whole React code is in the browser’s cache so it doesn’t need to request it from the server again, but parsing and initializing take time. The server still serves a page on each click, albeit a greatly simplified one. Less experienced devs may not even understand how the above stopgap works since online examples are either pure MPA or pure SPA.
Because simply starting over from scratch isn’t feasible while also maintaining the old codebase, this is a great checkpoint. It’s relatively easy to get here without undue work and without touching things all over the system. Localized change parallelizes nicely: one, two, or ten devs can work on individual pages without getting in each other’s way.
Separately, you can have other devs working on that API.
Providing API Endpoints
Even in legacy codebases, good architecture was once a concern. A common architecture for the app server is a three-layer cake of horizontal layers. The bottom layer translates database requests and responses to code objects. The topmost layer translates code objects to and from web pages. The middle layer is the “pure” layer that holds business logic, consolidates and aggregates data from several sources into single purpose-built objects, speaks through integration points to other systems, and acts as the entry point for nightly background jobs.
Given this I-shaped three-layer cake we’ll create a Y-shape from it. The API will sit on top of the middle layer, becoming a second topmost layer which is alternative to the existing topmost layer. This allows the newly budding API to re-use as much existing business logic and code as possible, reducing time to develop.
Or perhaps you have an architecture that is not so sound. Perhaps the middle layer calls upon the top layer in an inversion, or the business logic is sprinkled in those template files at the very top. Perhaps the very notion of layers isn’t present or is very poorly present. A V-shaped architecture might still be possible. The bottom layer usually uses an Object-Relational Mapper (ORM) or Query Builder library to do the heavy lifting in speaking with a database, and perhaps that can be the base of the new API arm.
List screens in particular deserve some extra consideration. A common driver of migrations is such screens lack features like sophisticated sorting, searching, filtering, and exporting. These features typically reach straight through all layers of the backend to the very SQL sent to the database. Always review requirements like this that seem like pure frontend features but actually are not. It may be that those features were left behind intentionally due to performance concerns; concerns which may have root in the backend codebase or may reside in the foreign key constraints and indexes of the database itself.
Tying Them Together
Once some pages have been half-migrated, and some API endpoints pass testing, the team can update each React hook to talk to the API. The API will need to know, say, the order number so it can fetch the correct one. Although React could pull this number out of the document JSON, it’s in the URL, which means it’s time for the React app to acquire a router.
A router library controls which “page” React is showing. It comes with tools to look at the https://example.com/orders/3659 url and pluck out the order number, and also to decide which component should show beside the topbar and lefthand menus. At this point of a migration, you only need to pluck that number from the url and pass it to the hook which passes it to the API. Done.
Now the “order-api-result” div in the server-made document is no longer needed, but do not spend the time to remove the div yet. Eventually its whole file will be deleted, and until then it’s a nice double-check if a bug is suspected in the growing API.
Navigation
The key difference between MPAs and SPAs is in the acronym: are there multiple pages or a single page? With a MPA, the entire page is dumped on clicking a link, while in a SPA the router library shows different content beside the topbar and left menu, using the API to fetch the raw data to fill it with specifics. This means there’s a lot of clickable spots in the React code that will begin life working one way (window.location.assign(url)) and later work a different way (router.navigate(url)).
One way to ease this transition is wrapping the router call in custom code, which perhaps consults a table to see whether window.location or the real router should be used. This would help ensure some navigation link isn’t forgotten.
There’s also the question of whether the React code should work to keep the exact same shape urls. Typically, yes, this will preserve user bookmarks and favorites. Occasionally the existing server code has not been following good URL design, such as omitting the order number in the url. In those cases go with good design, but leave a reasonable redirect for the old URL so bookmarks still work.
Related to this, some React libraries and meta-frameworks have extra stipulations that may conflict with the easiest migration path. For example, Next.js has its own backend separate from the API and it requires component files to be in a folder structure that matches the url structure. While that’s easy to understand for new devs on new projects as well as old devs on legacy servers, it can be an extra constraint for a project already trying to thread the needle. Staying flexible is key to an easy migration.
Auth
At some point, the issue of conflicting auth systems might emerge. Most legacy app servers use stateful auth, whereas API-based apps use stateless auth. When creating the API, you should stick to the standard recipes, and some backend languages and frameworks do in fact support both simultaneously. If you’re lucky, your legacy system is using a late enough version of its framework to do this.
Otherwise you’ll temporarily have two methods of auth in play at the same time. Under this regime the rule to uphold is, the lower layers of server code should never care which method was used. Once the auth gates of the topmost level have been passed, the current user and what roles or permissions that current user has should be in the same kind of common user object. Alter the outgoing auth system to match what the new standard wants to use.
The frontend has its own concerns. The existing login screen probably sets stateful auth in a cookie that is invisible to javascript, and redirects to a new page, dumping everything. A React login screen would return a token to javascript and the router changes which page is displayed. Javascript can store its token in sessionStorage, which survives page dumps as the cookie already does, so that part isn’t a problem. Given both, the frontend can always send both token (via Authorization: Bearer header) and cookie on all requests to the server. Presumably, the backend resource would simply use the one it wants and ignore the other. (Do remember to make their expiration times match.)
So how would the frontend acquire both? One way involves the frontend: upon realizing it lacks the bearer token, it calls a new endpoint that specifically exists for the purpose of checking for a cookie and returning a token. This endpoint may or may not be grouped with the rest of the new API’s endpoints since it uses the old cookie-based auth. If that endpoint detects no cookie, redirect the browser to the (old) login screen.
Another way is creating a React-based login screen and backend like new which only differs from the standard recipe in that it also creates and sets the cookie. This requires the backend framework explicitly create valid cookies imperatively. Whether it can provide this ability is again specific to the backend language and framework.
The End of the Road
As more pages are half-migrated with working API endpoints, the React app can consult the backend less on every click and swipe, instead letting its router control the user experience. As the number of page dumps and page requests drop to zero, the backend team can remove those pages as well as any code exclusive to them. Gradually, the backend codebase shrinks while the frontend codebase grows, and this cycle iterates until the migration is complete.