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.