Knowledge Mark G
Hire Me

ASP.NET MVC to React: How to Migrate Without Taking the Old Site Down

Move an ASP.NET MVC app to React or Next.js without forking the database or freezing the old site: the shared data layer, the EF migration trap, and the IIS deploy that silently does nothing.

By Published on · 7 min read · 4 views
Share:

Web Development

ASP.NET MVC to React: How to Migrate Without Taking the Old Site Down
  • ASP.NET Core
  • .NET
  • Entity Framework
  • Performance

Need help with this?

I build and fix business websites - fast, mobile-first and built to bring enquiries.

Get in touch

The advice you get for moving an ASP.NET MVC application to React is usually some version of "rewrite it". Freeze the old app, build the new one, cut over on a Saturday night. That works when somebody is paying for six months of parallel development. It does not work when the site has to keep earning while you change it.

This site went through exactly that move: a server-rendered ASP.NET Core MVC app with Razor views became an ASP.NET Core Web API with a Next.js front end. The old MVC app kept running the whole time, against the same database, with no schema fork and no data migration. What follows is how that was arranged, and the three things that actually went wrong.

The decision that makes or breaks this: who owns the database

Every other problem in this migration is downstream of one question. While both applications exist, which one owns the entity classes, the DbContext, and the EF Core migrations?

There are three answers, and two of them are bad.

Copy the models into the new project. The fastest to start and the worst to live with. You now have two definitions of the same table. The first time someone adds a column on one side, the two drift, and every bug after that is a schema mismatch nobody can see in either codebase.

Give the new app its own database and sync. Now you are writing a synchronisation layer, which is a whole product, and you are debugging it instead of shipping the migration.

Share one data layer between both applications. This is the one that works. Both apps compile the same entity classes, the same DbContext and the same migration history, so there is exactly one definition of the schema and exactly one database.

Sharing the data layer without moving a single file

The obvious way to share code is to extract the models into a class library and update both projects to reference it. That is the correct end state, and it is also a large, risky change to make on day one - you are moving every file the old application depends on before you have proved the new one works.

There is a smaller move that gets the same result. Leave the files exactly where they are, in the MVC project, and have the new project link them at compile time:

<!-- KnowledgeMarkG.Core.csproj -->
<ItemGroup>
  <Compile Include="..\..\Models\**\*.cs" LinkBase="Models" />
  <Compile Include="..\..\Data\ApplicationDbContext.cs" Link="Data\ApplicationDbContext.cs" />
  <Compile Include="..\..\Migrations\**\*.cs" LinkBase="Migrations" />
</ItemGroup>

Nothing was copied and nothing was moved. The MVC project still owns those files on disk and still builds exactly as it did. The new Core library compiles the same source into its own assembly, so both applications are provably working from one schema definition - if they diverge, the build breaks rather than production.

The practical effect is that you can start the new API on a Monday without touching the running site at all. When the old app is finally retired, the files move into the shared library properly and both .csproj files get simpler. That cleanup is safe by then, because nothing depends on the old project's layout any more.

What the API actually returns

A Razor view and a JSON endpoint want different things from the database. A view can lazily walk a navigation property because it renders in the same request; an API cannot, because that turns into an N+1 that only shows up under load.

Every read path in the new API projects straight from IQueryable into a DTO, so EF Core emits one flat SELECT with only the columns that get serialised:

public static readonly Expression<Func<BlogPost, PostSummaryDto>> ToSummary =
    p => new PostSummaryDto(
        p.Id, p.Title, p.Slug, p.Excerpt, p.PublishedAt,
        p.Categories.Select(c => new TermDto(c.Id, c.Name, c.Slug)).ToList());

No Include fan-out, no change tracking, and no lazy-load surprise later. If you are moving a Razor app that has been quietly relying on lazy loading for years, this is where the performance work is - not in the front end. I wrote up the rest of that exercise in taking this API from 900ms to 3ms.

Three things that actually went wrong

1. The EF migration history and the migration files drifted apart

This is the one that will cost you an afternoon, and it is specific to having two apps that can both run dotnet ef.

A migration was applied to the database under one generated ID. Later the migration file was regenerated and got a new timestamp in its name. The change was already in the database; the history table recorded the old ID. EF compared the files on disk against __EFMigrationsHistory, saw a file it had no record of, and tried to apply it again:

Column names in each table must be unique.
Column name 'ResumeUpdatedAt' in table 'SiteSettings' is specified more than once.

The instinct is to delete the migration or force the column. Both are wrong - the schema is correct, only the bookkeeping is not. The fix is to record the file's ID as applied, because it genuinely is:

IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory
               WHERE MigrationId = '20260920154332_AddResumeToSiteSettings')
    INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion)
    VALUES ('20260920154332_AddResumeToSiteSettings', '9.0.0');

Then dotnet ef database update runs clean and applies only what is genuinely pending. Check this before a deployment, not during one: database update applies every pending migration, so one stale record can make a routine deploy try to re-run something from three months ago.

2. Next.js on IIS does not start, and the error tells you nothing

If the .NET app is on Windows and IIS, the Next front end probably lands there too, behind iisnode. Next's standalone server does this:

const currentPort = parseInt(process.env.PORT, 10) || 3000

iisnode does not pass a TCP port. It passes a named pipe, something like \\.\pipe\1a2b3c. parseInt turns that into NaN, the server quietly falls back to port 3000, listens where iisnode is not looking, and every request returns "iisnode encountered an error" with HRESULT 0x2. Nothing in the log says "wrong port".

Patching that one line is not enough either, because Next eventually calls server.listen(port, hostname) and Node's pipe form of listen takes no hostname. The fix that holds is a small entry point that starts Next on a loopback TCP port and bridges the pipe to it. Both halves live in one process, so IIS still owns the lifetime.

3. Deploying over a running app silently does nothing

On Windows, IIS holds the application's DLL open while the app pool is running. Upload a new build over it and the file copy skips the locked files - and some control panels report success anyway. You get a green tick, the old code keeps running, and you spend an hour looking for a bug in a deployment that never happened.

Check the timestamp on the DLL after every deploy. If it has not changed, nothing changed. The reliable sequence on IIS is to drop an app_offline.htm into the application root first, which makes the ASP.NET Core Module shut the app down and release every file handle, then copy, then delete it.

What you gain, and what it costs

The honest trade is worth stating, because the usual write-up only lists the gains.

What gets better. The front end can be deployed without touching the API, and vice versa. Pages become genuinely cacheable at the edge instead of being rendered per request. The API is now usable by anything else you build - a mobile app, an integration, a second front end - because it returns data rather than HTML.

What gets worse. You now run and deploy two applications instead of one. Authentication becomes a real design decision rather than a cookie that happens to work. And a whole class of bug moves from compile time to runtime: a Razor view that referenced a renamed property failed the build, whereas a front end reading a renamed JSON field fails in the browser, in production, on a page you did not test. Typed API clients and a strict schema are not optional after this move; they are what replaces the compiler.

The order that keeps the site alive

  1. Stand up the API alongside the MVC app, sharing the data layer by linked compilation. Nothing user-facing changes. If it goes badly, delete the project.
  2. Move one read-only page. Something with no forms and no auth. You are proving the deployment, caching and DTO shape, not the whole application.
  3. Move the rest of the read paths, one route at a time, keeping the MVC route live until its replacement is in production.
  4. Move writes and authentication last. They are the parts with real consequences when they are half-migrated.
  5. Retire the MVC app, then move the shared files into a proper class library and simplify both projects.

The point of the ordering is that the migration is reversible at every step until step five. At no point is there a Saturday night with both versions half-working and no way back.

Is this worth doing at all?

Often, no. A Razor app that is fast, maintained and not blocking anything does not need a React front end because React is fashionable. The reasons that justify the work are concrete: you need a front end that something other than a browser can consume, your pages need to be served from a CDN rather than rendered per request, or you cannot hire for the stack any more.

If none of those apply, the better investment is usually making the MVC app faster. If one of them does, the migration above is the version that does not require betting the site on a cutover date.

Frequently asked questions

Can I migrate ASP.NET MVC to React without rewriting the whole application?+
Yes, and it is the safer route. Run the new API alongside the MVC app with both compiling the same entity classes and DbContext, then move one route at a time. The old site stays live throughout and every step before the final cleanup is reversible.
Should the new API have its own database?+
No. Two databases means writing synchronisation, which is a product in itself. Two copies of the entity classes against one database is worse still - they drift the first time someone adds a column, and the resulting bugs are invisible in both codebases. Share one data layer and one migration history.
How do I share entity classes between the MVC app and the new API?+
Link them at compile time rather than moving them. A Compile Include pointing at the MVC project's Models, DbContext and Migrations folders lets the new project build the same source without touching the running app. Move the files into a proper class library later, once the old application is retired.
Why does EF Core try to re-apply a migration that is already in the database?+
Because the migration file's ID no longer matches the one recorded in __EFMigrationsHistory - usually after a migration was regenerated and picked up a new timestamp. The schema is fine; the bookkeeping is not. Insert the file's ID into the history table and the next update runs clean.
Why does my Next.js app fail on IIS with 'iisnode encountered an error'?+
Next's standalone server reads process.env.PORT with parseInt, but iisnode passes a named pipe rather than a port number. parseInt returns NaN, Next falls back to port 3000, and listens where iisnode is not looking. Start Next on a loopback port and bridge the pipe to it from the same process.
Is it worth migrating an ASP.NET MVC app to React at all?+
Only for a concrete reason: you need the data consumed by something other than a browser, you need pages served from a CDN instead of rendered per request, or you can no longer hire for the stack. A fast, maintained Razor app that blocks nothing is usually better served by making it faster.

Join the conversation

No comments yet — be the first to share what you think.

Leave a comment

Never published.

Optional.

Keep reading

Related articles

View all