Introduction
If you've worked on a long-lived frontend, you already know the story. The app grows, features pile up, deadlines keep coming, and suddenly you're sitting on a mountain of technical debt.
That's exactly where we were.
We had a large Angular 14 application with 600+ components, a
monolithic structure, and increasing complexity that was slowing down
development. A full rewrite sounded tempting, but also risky,
expensive, and disruptive to the business.
So instead of going for a big-bang rewrite, we designed a migration
strategy that let us incrementally replace the legacy code using:
- Next.js
- GraphQL federation
- A monorepo architecture
- Web components as a bridge between frameworks
This post walks through the architecture, the migration approach, and the lessons we've learned so far.
The Legacy Landscape: What We Started With
Our Angular application had been the backbone of our business for years.
It handled:
- Customer and user registration workflows
- Service provider search
- Payment processing
- Role-based user management
- Complex multi-step forms
- AWS Cognito authentication
It worked. It delivered value. But it had started to show its age.
Key Challenges with the Legacy System
Monolithic architecture
One root module with 637 declared components made the codebase
hard to reason about.Manual dependency injection
A custom HTTP service was manually instantiated in 60+ places,
bypassing Angular's DI.Tight coupling
Components were directly tied to specific API shapes.Limited reusability
UI components were Angular-specific and couldn't be reused
elsewhere.Slow builds
Build times kept growing with the app.Technology debt
- Angular 14
- Bootstrap 4
- jQuery dependencies
The app had grown organically across multiple environments (dev, test,
uat, prod). A full rewrite would likely take 12-18 months and carry serious business risk.
So we needed a safer approach.
How the Pieces Fit Together
At a high level:
- Angular continues to run the legacy UI.
- New features are built in React.
- React apps are shipped as web components.
- GraphQL sits between the frontend and the backend.
- Next.js handles authentication.
This lets us replace features one at a time without disrupting the business.
Our Migration Strategy: Strangler Fig Pattern
We adopted the Strangler Fig pattern, gradually replacing parts of the system while the old one keeps running.
Our approach had three core pillars:
- Monorepo foundation
- GraphQL-based APIs
- Web components as a bridge
Monorepo with Turborepo
We built a monorepo using pnpm and Turborepo.
monorepo/
├── apps/
│ ├── auth-service/
│ ├── graphs/
│ ├── services/
│ └── notification-service/
├── packages/
│ ├── design-system/
│ ├── authentication/
│ ├── logger/
│ ├── database/
│ └── web-components/
Benefits
- Shared code across apps
- End‑to‑end TypeScript
- Faster builds (70% improvement)
- Atomic cross‑stack PRs
- Coordinated versioning
GraphQL as the API Layer
Instead of a monolithic REST API, we created a domain‑based GraphQL
services.
type Business {
id: ID!
name: String!
subscriptionPlans: [Plan!]!
defaultPlanId: Int
}
type Query {
searchBusinesses(country: String!, searchTerm: String!): [Business!]!
}
Advantages
- Clear domain separation
- Independent deployments
- Strong typing
- Efficient client‑driven queries
- Federation‑ready architecture
Web Components: React Inside Angular
We used web components to embed React features into the Angular app.
import { r2wc } from '@r2wc/react-to-web-component';
import { UserRegistrationWithApollo } from './UserRegistration';
const UserRegistrationWC = r2wc(UserRegistrationWithApollo, {
props: {
businessGraphApiUrl: 'string',
providerGraphApiUrl: 'string',
},
});
customElements.define('user-registration', UserRegistrationWC);
Angular usage
<user-registration
[businessGraphApiUrl]="businessApiUrl"
[providerGraphApiUrl]="providerApiUrl">
</user-registration>
Why this worked
- Framework‑agnostic UI
- Incremental migration
- Modern React patterns
- Reusable across apps
Apollo Client: Multi‑Graph Communication
export const createApolloClients = (
businessUri: string,
providerUri: string
) => {
const businessClient = new ApolloClient({
link: authLink.concat(httpLink(businessUri)),
cache: new InMemoryCache(),
});
const providerClient = new ApolloClient({
link: authLink.concat(httpLink(providerUri)),
cache: new InMemoryCache(),
});
return { businessClient, providerClient };
};
Next.js for Authentication
export async function POST(request: Request) {
const { refreshToken } = await request.json();
const newTokens = await refreshCognitoToken(refreshToken);
return Response.json({
accessToken: newTokens.accessToken,
idToken: newTokens.idToken
});
}
Why Next.js
- API routes for auth
- Docker‑ready builds
- Shared between Angular and React
- Future‑proof for migration
Database Layer: Prisma + SQL Server
@Injectable()
export class DataService {
constructor(private prisma: PrismaClient) {}
async getBusiness(id: number) {
return this.prisma.business.findUnique({
where: { id },
include: {
subscriptionPlans: true,
locations: true
}
});
}
}
Migration Workflow
Step 1: Build in React
export const Feature = () => {
const { data, loading } = useQuery(GET_DATA_QUERY);
if (loading) return <Spinner />;
return (
<Card>
<CardHeader>
<CardTitle>{data.title}</CardTitle>
</CardHeader>
<CardContent>
{/* Feature implementation */}
</CardContent>
</Card>
);
};
Step 2: Wrap as Web Component
const FeatureWC = r2wc(FeatureWithApollo, {
props: {
apiUrl: 'string',
userId: 'string'
}
});
customElements.define('app-feature', FeatureWC);
Step 3: Use in Angular
import '@company/wc-feature';
<app-feature
[apiUrl]="apiUrl"
[userId]="currentUser.id">
</app-feature>
Step 4: Feature Flag
<app-feature *ngIf="featureFlags.useNewFeature"></app-feature>
<legacy-feature *ngIf="!featureFlags.useNewFeature"></legacy-feature>
Step 5: Remove Old Code
- Remove flag
- Delete Angular component
- Clean up services
- Update tests
Key Metrics After 6 Months
- 15 major features migrated
- 70% faster builds
- 40% less duplicate code
- 80% of new features built in React
- Zero migration‑related incidents
Final Thoughts
Sunsetting a legacy app doesn't have to mean a risky rewrite.
By combining:
- A monorepo
- GraphQL
- Web components
- Next.js
- Turborepo
...we've been able to modernise one feature at a time while keeping the business running smoothly and making life easier for developers.
The strangler fig pattern really works. It is a framework for thinking about incremental migration instead of forcing a “big bang” rewrite or living with legacy forever. Each new feature in React, each GraphQL service, each shared package brings more value immediately while paving the way for the next step.
What we’ve realised is that modernisation is not just about technology. It’s about workflow, confidence, and developer experience. You can gradually introduce modern tools and patterns, reduce duplicate code, improve type safety, and simplify builds, all while delivering the features your business actually needs.
At the end of the day, you do not have to choose between chaos and stagnation. Incremental migration lets you move forward with clarity, modernise without drama, and build a foundation that can grow with your team and your users for years to come.
Top comments (22)
Just wanted to add that we’ve leaned on generative AI to make navigating our legacy Angular code less painful. The stats in this blog, like which components are purely legacy versus newly added are analyzed by Claude Code. We also use Claude.md files to locate and fix tricky bugs in legacy code, which can be hard to reason about manually. More details here
Claude Code doesn't have a back door ? There are no security concerns when it comes to sharing the code with that AI service ?
That’s a very fair question.
We’re using the enterprise version of Claude Code, so it runs under enterprise security controls and contractual data protection terms. There’s no (hopefully) “back door” access to our systems, and it doesn’t get direct access to production infrastructure.
Access is restricted to specific repositories, and we’re careful about what gets shared. No secrets, no production credentials, and no customer data are exposed. It’s treated like any other third-party engineering tool, with security review and internal guardrails in place.
For us, it’s been more like an intelligent code navigation assistant than a blind code dump into the internet.
is going to be so much better when companies can run their own instances.
That’s an interesting thought. More control and ownership would definitely appeal to a lot of people, especially those who care about privacy and governance.
If you're tracking CWV during the migration: you could use GSC’s CWV report by URL group (e.g. home vs product vs checkout) so you can see if new Next.js routes perform better than legacy ones. Lab scores (Lighthouse) help debug; field data confirms real impact.
Oh that’s interesting 👀
We’re not tracking CWV that way yet, but grouping by URL in GSC during the migration sounds like a really smart approach. Definitely something I can look into as the migration progresses.
Thanks for the suggestion 🙌
Hi Ujja,
Thanks for this post. Loved it. Was very beneficial.
I have some quick questions:
Thanks again. best wishes
Hi Alptekin, really appreciate you reading it so closely 🙂
We still have Angular in place. It hasn’t been fully replaced yet. We’re incrementally migrating features to React via web components, so Angular continues to run the legacy parts. We did minor upgrades for stability, but no major refactor of the Angular app itself.
For data flow, we didn’t build Angular ↔ React “internal APIs.” Communication happens through props and custom DOM events at the web component boundary. Data fetching goes directly from React to GraphQL services, and Angular does the same for legacy flows.
Next.js is mainly handling authentication and shared API routes, not acting as a bridge layer between Angular and React.
Happy to dive deeper if helpful 🙂
thanks for your answers Ujja.
Massive front-end undertake. Great strategy for improvement 👏
Appreciate that 🙌
It definitely feels massive some days 😅 but breaking it down feature by feature makes it manageable. The strangler approach gave us breathing room instead of forcing a risky all-or-nothing rewrite.
Still a long road ahead, but at least now it feels structured rather than chaotic.
I like when that happens. Suddenly things are easier to understand and the development experience becomes enjoyable again.
Exactly. That moment when things start making sense again is such a relief 😅
hahahah yea
I speach as profane... just for reasoning, I dont fully agree...
I think a tractor is a good substitute for a hoe, and it doesnt matter if I forget how to swing the hoe, and for muscles and keep my body in movements I can go to the gym
OK the programmer wont see the bug if he doesnt understand the code...
OK AI can introduce vulnerabilities or logic bugs...
but this is the argue... AI is the tractor... I have to learn to drive it and use it well:
And so on... everything reduces something and increases something else.
I like the tractor analogy.
I agree that every major shift in tooling changes the skill set rather than removing it. We moved from manual debugging to IDEs, from raw HTTP to frameworks, from REST to GraphQL. Each layer abstracts something and demands new discipline elsewhere.
With AI, I don’t see it as replacing understanding. If anything, it amplifies the cost of not understanding. You still need architectural judgment, security awareness, and the ability to spot when something “feels wrong.”
For me, the real shift is moving from writing every line to reviewing, steering, and validating more intentionally.
Have you tried to upgrade to Angular 21 (with signals) first? 😂
There was one problem. Now there are two )
Haha, fair point 😄
We did look at upgrading first. The problem is the app is quite fragile at this point. It has a lot of tightly coupled pieces and third party dependencies that were built around older versions. Some of those libraries either do not support newer Angular versions properly, or upgrading them changes behavior in ways that break existing flows.
So a straight jump to Angular 21 with signals was not as simple as it sounds. It would likely have triggered a long chain of upgrades and regressions across payments, auth, and forms.
Embedding React does add complexity, I agree. But for us it is more controlled and incremental. We are slowly shrinking the legacy surface instead of trying to renovate a very fragile house all at once.
How are you handling shared state between the Angular and React parts during the transition?
We avoided a shared in-memory store between Angular and React and used a props, events, and slots model.
How it worked:
Props into web components: Angular passes config and attributes directly into the React web components.
Custom DOM events out: React components emit events (e.g., registration-complete) that Angular listens to for navigation or UI updates.Though we avoided this as much as possible.
Slots for layout/content injection: Some web components expose slots so Angular can provide headers, actions, or contextual UI without managing internal state. This was mainly because of the portal structure we have.
Shared session storage: Both sides read auth/session data from the same place (cookies or localStorage).
In short, no shared Redux-style store. Angular provides inputs via props and slots, React emits events, and session data is shared through browser storage.
Also wanted to add one more point about GraphQL. We’re mainly using it within the monorepo setup right now. We haven’t fully sunset the legacy backend yet, that’s still in progress.
I’ll be writing another blog soon that dives into that part of the journey 🙂