← Back to writingArchitecture / 2026.03

What Happens When You Jump From AngularJS 1.5 Straight to React 19

I recently rebuilt a production portal from AngularJS 1.5 to React 19. Not Angular 2+, not an intermediate version, just straight from AngularJS to a modern React setup with strict TypeScript, Nx, Vite and a completely different build pipeline.

The system is a multi-tenant review management platform used by thousands of businesses, so the rewrite had to be done while the old portal was still running.

This isn't really about React vs Angular. It's more about what shows up when a codebase skips several generations of frontend tooling.

Old AngularJS UI screenshot.

1. The old codebase was structured, just built for a different stack

The AngularJS portal had a few hundred TypeScript files, a large number of controllers, and a route tree that was built dynamically depending on user roles and feature flags. It was not random or disorganized, it followed the patterns that were common for AngularJS at the time.

There was heavy use of $rootScope events, UI-Router resolves for loading data, two-way binding in forms, and controllers that handled state, API calls and DOM logic in the same place. All of that worked fine when the application was smaller, but it becomes difficult to extend once the codebase grows.

Instead of rewriting everything from scratch, it helped to understand what each pattern was doing and then replace it with the closest modern equivalent.

The old ApplicationContext provider, which stored user, tenant, token and feature flags, became a Redux slice with typed selectors. Conditional route registration became protected route components. The problems being solved stayed the same, only the tools changed.

Old vs new architecture diagram.

2. One SPA was actually four different applications

The legacy portal was one AngularJS application, but it served four different user types:

  • tenant admins
  • business owners
  • location managers
  • platform admins

Routes were registered during boot depending on the logged-in user, so parts of the portal simply didn't exist for certain roles.

This worked, but everything still lived in the same bundle and the same runtime.

That meant changes in one area could affect another, CSS could leak between sections, and developers had to understand the whole portal even if they were only working on one part.

In the rewrite the portal was split into four apps inside an Nx monorepo.

apps/ ├── kv-frontend ├── kv-location ├── kv-location-dashboard ├── kv-super-admin └── routes

Each app has its own entry point, build config and container, but they share a single router so navigation stays consistent.

This keeps the UI feeling like one product while still allowing each area to be built and deployed independently.

Monorepo structure screenshot.

3. The build pipeline caused more problems than the framework

The AngularJS code itself was not the biggest issue. The build setup was.

The legacy portal used Gulp 3, Webpack 1 and server-side templates. The output bundle was injected into a FreeMarker template that was rendered by the Java backend, so the frontend could not be deployed on its own.

Changing one component meant running the full Gulp pipeline and reloading the entire app. There was no hot reload, no tree-shaking and no separation between frontend and backend builds.

After moving to Nx and Vite, builds became much faster, but the more important change was that the frontend became independent.

New pipeline build screenshot.

The new portal builds to static assets, runs in its own container and is deployed separately from the backend. The backend only provides API endpoints.

4. Strict TypeScript helped during the rewrite

The old code used TypeScript with loose settings, so a lot of things compiled even if the types didn't fully match.

The new project runs with strict mode enabled, including checks for unused values, missing returns and implicit any types.

During the rewrite this actually made the process easier because the compiler pointed out places where the old code relied on behaviour that was never explicit.

Examples were API responses with optional fields that were treated as required, error paths that never returned anything, and values that could be undefined but were used without checks.

AngularJS templates tend to hide those problems, but React with strict TypeScript does not.

Treating the compiler as a guide instead of something to work around made the migration more predictable.

The stricter setup also included custom ESLint rules for multi-tenant safety and dependency boundaries. A simplified example:

module.exports = { root: true, plugins: ["@nx", "react-hooks", "@kv-frontend/custom-rules"], overrides: [ { files: ["*.ts", "*.tsx", "*.js", "*.jsx"], rules: { "@nx/enforce-module-boundaries": [ "error", { enforceBuildableLibDependency: true, allow: [], depConstraints: [ { sourceTag: "*", onlyDependOnLibsWithTags: ["*"], }, ], }, ], "react-hooks/exhaustive-deps": [ "warn", { additionalHooks: "(useMemo|useCallback)", enableDangerousAutofixThisMayCauseInfiniteLoops: false, }, ], "@kv-frontend/custom-rules/no-token-tenantid-fallback": "error", "@kv-frontend/custom-rules/no-state-unknown": "off", }, }, ], };

And the dedicated custom-rules config in shared/data-access libraries looked like this:

{ "extends": [], "ignorePatterns": ["!**/*"], "plugins": ["@kv-frontend/custom-rules"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "plugins": ["@kv-frontend/custom-rules"], "rules": { "@kv-frontend/custom-rules/no-token-tenantid-fallback": "error", "@kv-frontend/custom-rules/no-state-unknown": "error" } }, { "files": ["*.ts", "*.tsx"], "plugins": ["@kv-frontend/custom-rules"], "rules": { "@kv-frontend/custom-rules/no-token-tenantid-fallback": "error", "@kv-frontend/custom-rules/no-state-unknown": "error" } }, { "files": ["*.js", "*.jsx"], "plugins": ["@kv-frontend/custom-rules"], "rules": { "@kv-frontend/custom-rules/no-token-tenantid-fallback": "error", "@kv-frontend/custom-rules/no-state-unknown": "error" } } ] }

5. Moving layout logic to containers instead of components

The old portal had a lot of small layout fixes spread across different components. Different parts of the UI handled resizing and scaling in different ways, which made behaviour inconsistent.

In the new version most of that logic sits in container components instead.

Components render at a fixed layout and the container handles resizing, scaling and positioning. This reduced the amount of repeated code and made layout behaviour consistent across the portal.

This also made white-labeling easier to manage. Instead of pushing tenant-specific styling into every screen, the layout layer applies theme context once through ThemeInjector, while route and auth state decides which shell components should render.

import { RootState } from "@kv-frontend/data-access"; import { AuthorizedHeader, PageHeading, ThemeInjector, UnauthorizedHeader, RouteTransitionProvider, useRouteTransition, } from "@kv-frontend/shared"; import Cookies from "js-cookie"; import { useEffect, useRef, useState } from "react"; import { useSelector } from "react-redux"; import { useLocation, useNavigate } from "react-router-dom"; const FrontendPageLayoutContent = () => { const { isLogged, selectedTenantId, loggedInUser, hash } = useSelector( (state: RootState) => state.auth, ); const { isSwitching } = useRouteTransition(); const navigate = useNavigate(); const location = useLocation(); const [isInitialized, setIsInitialized] = useState(false); const wasLoggedInRef = useRef(isLogged); const [showHeaderDuringTransition, setShowHeaderDuringTransition] = useState(false); useEffect(() => { const kvcookie = Cookies.get("kvcookie"); if (kvcookie && location.pathname.includes("user/login")) { navigate("/dashboard"); } }, [location.pathname, navigate]); useEffect(() => { if (wasLoggedInRef.current && !isLogged) { setShowHeaderDuringTransition(true); const timer = setTimeout(() => setShowHeaderDuringTransition(false), 500); return () => clearTimeout(timer); } wasLoggedInRef.current = isLogged; return undefined; }, [isLogged]); if (!isInitialized && !location.pathname.includes("user/login")) { return ( <div> <ThemeInjector /> <div>Loading...</div> </div> ); } const getHeader = () => { if (isSwitching && isLogged) return <AuthorizedHeader />; if (showHeaderDuringTransition) return <AuthorizedHeader />; if (location.pathname.includes("user/login") && hash && !loggedInUser) return null; return isLogged ? <AuthorizedHeader /> : <UnauthorizedHeader />; }; return ( <div> <ThemeInjector /> {getHeader()} {!location.pathname.includes("user/login") && <PageHeading />} {/* FrontendMain */} </div> ); }; export const FrontendPageLayout = () => ( <RouteTransitionProvider appType="frontend"> <FrontendPageLayoutContent /> </RouteTransitionProvider> );

6. Rebuilding routes based on user flows instead of file structure

The legacy portal had a large number of routes, and the first attempt at the rewrite was to map them one-to-one.

That didn't make much sense once the actual usage was checked. Users were mostly following a small number of flows, but the route tree had grown over time as new features were added.

During the rewrite, routes were grouped around workflows instead of the original file structure. The URLs stayed compatible, but the internal layout became simpler.

Router structure screenshot.

A lot of the complexity in the old router existed because of framework constraints at the time, not because the product needed that many separate pages.

7. Shared code needs clear boundaries

The old project had a shared folder that everything imported from. Over time that became the place where anything went if it didn't fit somewhere else.

In the new monorepo shared code is split into libraries with defined responsibilities:

  • shared UI components
  • data access and API logic
  • grid / table components
  • application code per portal

Shared variables and library boundaries screenshot.

If something goes into a shared library it has to belong there, otherwise the same coupling problems come back.

8. React 19 features that were actually useful

Most of the improvement did not come from switching frameworks, but some newer React features did help.

startTransition helped with expensive renders in large tables. useOptimistic simplified async updates. Direct ref props reduced boilerplate in the component library. React Hook Form replaced a lot of fragile form logic from the old portal.

The main difference came from stricter typing, better tooling and clearer boundaries, not from React itself.

9. Result after the rewrite

Performance improved, but that was not the main benefit.

Splitting the portal into separate apps made it easier to work on one area without affecting the rest. Strict TypeScript reduced runtime errors. Independent builds made deployment simpler.

The biggest difference is that the system is easier to change without needing to understand the entire codebase first.

Stack

Before AngularJS 1.5 - TypeScript 2 - Gulp 3 - Webpack 1 - LESS - FreeMarker - Karma - PhantomJS

After React 19 - TypeScript 5.9 - Nx - Vite - SCSS - Redux Toolkit - React Router v6 - Vitest - Docker - Kubernetes

The rewrite was less about switching frameworks and more about removing constraints that had built up over time.

Tech used in this article

  • TypeScript
  • React
  • Angular
  • Nx
  • Vite
  • Redux
  • Docker
  • Kubernetes