How I migrated a WebForms monolith to a .NET 8 API and React SPA

For years I was the person keeping an ASP.NET WebForms app alive. It ran the whole business: sales reps, their orders, commissions, the companies they represent. It also ran exclusively on Windows, leaned on a DevExpress control suite from 2011, and printed its reports through an engine that refused to build anywhere except one very specific machine. Every change felt like defusing something. This is the story of how I rewrote it as a .NET 8 API and a React front end, and why getting it off Windows turned out to be the part that changed the most.

TL;DR: I rebuilt a legacy ASP.NET WebForms system as a .NET 8 REST API and a React SPA, kept the existing MySQL database untouched, and moved the whole thing onto Linux.
Stack: ASP.NET Core 8, EF Core 8, Pomelo MySQL, JWT, QuestPDF, React 18, TypeScript, Vite, Tailwind, TanStack Query, Linux, Docker
Level: Intermediate
Reading time: ~9 min

Here is what actually kept me stuck, and it was two problems wearing one coat. The first is WebForms itself: state lives in ViewState, every click is a full server round trip, and the markup, the C# behind it, and the database calls all share the same page. There is no seam to grab. The second is that the whole thing was chained to Windows: it needed IIS to run, it needed a Windows box to compile the reports, and even my development machine had to be Windows to open it. So the cost was never just “it looks old.” The cost was that I could not put a second client, an automated test, or a cheap Linux server anywhere near it without the entire Windows-shaped framework coming along for the ride.

The moment it really hit me was a reporting change. A sales rep wanted one extra column on an order PDF, a five minute job in any sane stack. To ship it, I had to boot a Windows VM that held the exact ReportViewer assemblies, because nothing else on earth could build that .rdlc. While I was in there, I scrolled past files named Pedido-MICRO4341.cs, little fossils of every “I’ll clean this up later” I had ever told myself. That was the afternoon I decided the database could stay and everything sitting on top of it was leaving.

Keeping the database, replacing everything above it

The one rule I gave myself was that the MySQL schema would not move. It is shared with the system still serving real reps in production, so a migration that “improved” a column was a migration that could take the business down at 9am. I picked EF Core for the new data layer but turned the usual workflow upside down: no migrations, no model-first. The database is the source of truth, and my C# entities bend to fit whatever the legacy schema already says, quirks and all. The old RepDLL had a class, a DAO, and a BO for every single entity, each one hand-writing SQL through a MySQL facade. All of that collapsed into plain entities and one DbContext.

var serverVersion = new MySqlServerVersion(new Version(8, 4, 0));
builder.Services.AddDbContext<RepresentanetDbContext>(opts =>
    opts.UseMySql(connectionString, serverVersion));

I even pinned the server version by hand instead of letting Pomelo auto-detect it, because auto-detect opens an extra connection at startup just to ask MySQL what version it is, and on the shared hosting this runs on, every wasted connection counts against a hard limit. Small thing, but those are the details you learn the hard way.

Why .NET Core, and what Linux actually changed

This is the decision I am happiest about. The old app was .NET Framework 4.8, which means Windows and nothing else. .NET 8 runs the exact same C# on Linux, and that one fact rippled through everything else. I stopped needing a Windows Server license just to host a web app. I could finally build a Docker image and get a deploy that is byte-for-byte identical on my laptop and on the server, instead of “it works on the one VM nobody is allowed to touch.” The runtime itself is dramatically faster than Framework ever was, and ASP.NET Core ships its own web server, Kestrel (yes, named after the falcon), so the app listens for requests directly without IIS sitting in front of it. The detail that still makes me smile: I am writing this post on the same Linux machine the API now runs on. The old stack would not have let me develop there at all.

Turning pages into an API

Every .aspx that used to post back to itself became a thin controller handing off to a service. BOPedido became PedidoService, registered once in the container, and PedidosController just exposes it over HTTP and returns JSON. Dependency injection is part of the framework now, so I stopped wiring objects together by hand the way the old code did. The piece I was most careful about was multi-tenancy, because a sales rep must never see another rep’s orders.

builder.Services.AddScoped<IPedidoService, PedidoService>();
builder.Services.AddScoped<IPerfilContexto, PerfilContexto>();

PerfilContexto reads the id_representante claim off the token and filters every query by it. The old app leaned on session state for the same job, which is exactly the kind of implicit rule that quietly breaks the moment a second client shows up.

One API for the web and the phone

The legacy system had a separate SOAP service bolted on just so the mobile app could reach the same data, which meant the same business rules lived in two codebases and drifted apart over time. That whole service is gone. The React app and the mobile app now call the same REST endpoints with the same JWT, so there is one set of rules and one place to change them. Auth went stateless too: the API hands out a token, the client sends it back on every request, and there is no server session to keep alive.

api.interceptors.request.use((config) => {
  const token = localStorage.getItem('auth_token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

Reports that run anywhere

The reporting engine that started this whole journey was the first thing I tore out. Those .rdlc files became QuestPDF generators, plain C# classes like PedidoPdfGenerator that describe the layout in code. They run wherever .NET 8 runs, which now means a Linux container, so the five minute column change is finally a five minute change. Spreadsheet exports go through ClosedXML the same way. No VM, no sacred assemblies, no ceremony.

A front end I actually want to open

The DevExpress grid and the postback dance got replaced by a React front end in TypeScript, one folder per domain, each talking to a typed API module. TanStack Query handles caching and refetching, so sorting a column no longer reloads the entire page. The charts that used to render on the server became Recharts components fed by a plain /graficos endpoint, so the browser draws them and the server just ships numbers. Tailwind took the place of a pile of CSS nobody wanted to own. None of it needs a license, which after years of DevExpress felt strange in a good way.

Where it landed

I kept the MySQL database exactly where it was and rebuilt everything above it. The system that used to demand Windows, IIS, a licensed control suite, and one holy report VM now runs as a .NET 8 API and a React app, in a container, on Linux, served by Kestrel, with a single REST API feeding both the web and the mobile client. It is faster, I can test it, I can deploy it the same way every time, and I can work on it from the same Linux laptop I am typing this on. The Pedido-MICRO4341.cs files did not make the trip.

Questions or feedback? Find me on LinkedIn or GitHub.

Leave a Comment