When I joined the team at Lit Alerts, I inherited a monolithic Blazor/.NET dashboard with over 50 API endpoints — and zero documentation for any of them. The frontend was tightly coupled to server-side rendering, the codebase was brittle, and adding a new feature meant navigating a maze of undocumented dependencies.
The business couldn't pause for a rewrite. New features needed to ship while we modernized. Here's how I migrated the dashboard to React and TypeScript without stopping the train.
Choosing the Strangler Fig Pattern
A full rewrite was off the table. Instead, we used the strangler fig pattern — gradually replacing legacy UI components with React equivalents while the .NET backend continued serving both old and new frontends.
The process worked in three phases:
- API discovery — Reverse-engineer every endpoint the legacy frontend consumed
- Type contracts — Create TypeScript interfaces for every API response
- Component replacement — Rebuild UI features one at a time, starting with the highest-value screens
This approach let us deliver new features in React while the legacy dashboard stayed operational. Over time, the new frontend absorbed more and more of the old one.
Reverse-Engineering 50+ Undocumented Endpoints
This was the most tedious part. I spent the first two weeks just capturing network traffic. Every click in the legacy dashboard, I'd watch the Network tab, record the request/response, and document it in a TypeScript interface.
// Typed from observing /api/legacy/pipelines/:id
interface PipelineStatus {
id: string
name: string
status: 'running' | 'failed' | 'completed' | 'pending'
lastUpdated: string
metrics: {
documentsProcessed: number
errorCount: number
avgProcessingTimeMs: number
}
}
interface PipelineListResponse {
pipelines: PipelineStatus[]
total: number
page: number
}
async function fetchPipelines(page = 1): Promise<PipelineListResponse> {
const res = await fetch(`/api/legacy/pipelines?page=${page}`)
if (!res.ok) throw new Error(`Pipeline fetch failed: ${res.status}`)
return res.json()
}
Typing these endpoints caught bugs that had been lurking in the legacy code for months. Fields that were sometimes null, status values that didn't match the UI labels, timestamps in inconsistent formats. The TypeScript compiler surfaced all of it.
The single most valuable thing you can do during a migration is type your APIs. It's tedious upfront, but it pays for itself within the first week.
Building the React Component Layer
With typed API clients in hand, building React components became straightforward. I followed a pattern: one component per dashboard widget, each consuming a typed API client.
function PipelineCard({ pipeline }: { pipeline: PipelineStatus }) {
const statusColors: Record<PipelineStatus['status'], string> = {
running: 'text-blue-600 bg-blue-50',
completed: 'text-green-600 bg-green-50',
failed: 'text-red-600 bg-red-50',
pending: 'text-amber-600 bg-amber-50',
}
return (
<div className="rounded-xl border p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold">{pipeline.name}</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusColors[pipeline.status]}`}>
{pipeline.status}
</span>
</div>
<div className="grid grid-cols-3 gap-4 text-sm text-gray-600">
<div>
<span className="block text-2xl font-bold text-gray-900">
{pipeline.metrics.documentsProcessed.toLocaleString()}
</span>
Documents processed
</div>
<div>
<span className="block text-2xl font-bold text-gray-900">
{pipeline.metrics.errorCount}
</span>
Errors
</div>
<div>
<span className="block text-2xl font-bold text-gray-900">
{pipeline.metrics.avgProcessingTimeMs}ms
</span>
Avg processing time
</div>
</div>
</div>
)
}
The component-based architecture made testing trivial. Each widget could be developed, tested, and deployed independently. We rolled out new React components behind feature flags, so we could instantly fall back to the legacy UI if anything broke.
The Hard Parts Nobody Talks About
State synchronization. During the transition, some screens were React and others were .NET. Users navigated between both. We had to ensure shared state — like auth tokens, user preferences, and active filters — stayed consistent across both systems.
Build pipeline integration. The React build had to coexist with the .NET build. We configured Vite to output to a directory that the .NET server could serve as static assets, and used a reverse proxy to route between the old and new frontends based on the URL path.
Team buy-in. Not everyone was convinced the migration was worth the effort. Showing early wins — faster load times, fewer bugs in migrated screens, faster feature development — built momentum.
Results
After three months of incremental migration:
- Developer velocity doubled — New features that took a week in Blazor shipped in two days in React
- Bug reports dropped 40% — TypeScript caught classes of errors that .NET's loose frontend typing missed
- First meaningful paint improved by 50% — React's component model and code splitting delivered smaller, faster pages
- Onboarding time halved — New developers could contribute to the React codebase in days, not weeks
What I'd Do Differently
If I were starting this migration today, I'd invest more time upfront in automated API contract testing. We typed everything manually, which was thorough but slow. Tools like OpenAPI spec generation from .NET controllers could have accelerated the discovery phase.
I'd also start with a design system. We built components ad-hoc and later had to reconcile inconsistent styles. Starting with a shared component library from day one would have saved significant refactoring time.
The core strategy — strangler fig, typed API contracts, incremental component replacement — is sound. It's the approach I'd recommend to any team staring down a legacy migration.