Migrating a Legacy Pug/Express App to React + NestJS: An 85% Performance Gain
Abhishek Sharma
Software Developer
Migrating a Legacy Pug/Express App to React + NestJS: An 85% Performance Gain
At PharmaEdge.ai, we inherited a competitive intelligence application that had been growing organically for three years. Built on Express.js with Pug server-rendered templates, every user interaction triggered a full round-trip to the server, re-rendered an entire HTML page, and shipped it back down the wire. The frontend was a 2.5MB monolithic bundle. The average page load clocked in at 3.2 seconds. Feature development was glacial because business logic was entangled with Pug mixins and Express route handlers in ways that made isolated testing nearly impossible.
This is the story of how we migrated that application to React and NestJS using the strangler fig pattern, integrated Cerbos for policy-as-code authorization, and cut page load times by 85%.
Why Not a Big-Bang Rewrite?
The first instinct when facing a legacy codebase is to rewrite everything from scratch. I have seen this approach fail more often than it succeeds, and the reasons are predictable. A big-bang rewrite means you are maintaining two codebases simultaneously for months. The old system continues to accumulate bug fixes and feature requests that you have to port to the new system. Feature parity becomes a moving target. And when you finally flip the switch, you discover edge cases in production that nobody documented.
The strangler fig pattern solves this by letting you replace the legacy system incrementally, one route at a time. The name comes from the strangler fig tree, which wraps around a host tree and gradually replaces it. In our case, the host tree was Express/Pug, and the fig was React/NestJS.
The Strangler Fig Architecture
The key architectural decision was placing Nginx in front of both the legacy Express app and the new React SPA. Nginx acts as a router: requests for migrated routes go to the React app, and everything else goes to the old Express server. As we migrated each route, we updated the Nginx configuration to redirect traffic.
# nginx.conf — strangler fig routing
upstream legacy_app {
server 127.0.0.1:3000; # Express/Pug
}
upstream new_frontend {
server 127.0.0.1:4000; # React SPA (served via Vite preview or static)
}
upstream new_api {
server 127.0.0.1:5000; # NestJS API
}
server {
listen 443 ssl;
server_name app.pharmaedge.ai;
# Phase 1: Migrated routes go to React
location /dashboard {
proxy_pass http://new_frontend;
}
location /reports {
proxy_pass http://new_frontend;
}
location /api/v2/ {
proxy_pass http://new_api;
}
# Everything else stays on legacy
location / {
proxy_pass http://legacy_app;
}
}
This approach gave us three critical advantages. First, zero risk: if a migrated route had issues, we could revert the Nginx config in seconds. Second, independent deployments: the React app and Express app had separate CI/CD pipelines. Third, gradual team adoption: developers could learn React on lower-risk routes before tackling complex ones.
Running Pug and React Side by Side
The trickiest part of the strangler fig pattern is session sharing. Users navigating from a legacy Pug page to a migrated React page should not be forced to log in again. We solved this by extracting authentication into a shared JWT layer.
// shared-auth-middleware.ts — used by both Express and NestJS
import jwt from 'jsonwebtoken';
export interface TokenPayload {
userId: string;
email: string;
roles: string[];
tenantId: string;
permissions: string[];
}
export function verifyToken(token: string): TokenPayload {
return jwt.verify(token, process.env.JWT_SECRET!) as TokenPayload;
}
// Express middleware (legacy app)
export function expressAuthMiddleware(req, res, next) {
const token = req.cookies['pe_access_token']
|| req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.redirect('/login');
try {
req.user = verifyToken(token);
next();
} catch {
res.clearCookie('pe_access_token');
return res.redirect('/login');
}
}
// NestJS guard (new app)
@Injectable()
export class JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.cookies['pe_access_token']
|| request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedException();
try {
request.user = verifyToken(token);
return true;
} catch {
throw new UnauthorizedException();
}
}
}
Both the legacy Express app and the new NestJS app read the same JWT from the same cookie. The login page itself was one of the last routes we migrated, precisely because it was the shared dependency. During the transition period, users logged in through the legacy Pug login page, which set the JWT cookie, and then navigated seamlessly between old and new pages.
NestJS Module Architecture
NestJS enforces modular architecture by design, which was exactly what the legacy Express codebase lacked. We organized the new backend into domain modules, each encapsulating its own controllers, services, DTOs, and entities.
// src/modules structure
src/
├── modules/
│ ├── auth/
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── guards/
│ │ │ ├── jwt-auth.guard.ts
│ │ │ └── cerbos-policy.guard.ts
│ │ └── strategies/
│ │ └── jwt.strategy.ts
│ ├── reports/
│ │ ├── reports.module.ts
│ │ ├── reports.controller.ts
│ │ ├── reports.service.ts
│ │ ├── dto/
│ │ │ ├── create-report.dto.ts
│ │ │ └── report-filters.dto.ts
│ │ └── entities/
│ │ └── report.entity.ts
│ ├── competitive-intel/
│ │ ├── competitive-intel.module.ts
│ │ ├── services/
│ │ │ ├── drug-pipeline.service.ts
│ │ │ ├── patent-monitor.service.ts
│ │ │ └── market-analysis.service.ts
│ │ └── controllers/
│ │ └── competitive-intel.controller.ts
│ └── notifications/
│ ├── notifications.module.ts
│ └── notifications.gateway.ts # WebSocket
├── common/
│ ├── filters/
│ │ └── http-exception.filter.ts
│ ├── interceptors/
│ │ ├── logging.interceptor.ts
│ │ └── transform.interceptor.ts
│ └── decorators/
│ ├── current-user.decorator.ts
│ └── cerbos-check.decorator.ts
└── app.module.ts
Each module registers its own providers and exports only what other modules need. The competitive-intel module, for example, encapsulates three services for drug pipeline tracking, patent monitoring, and market analysis. In the legacy Express app, all of this logic lived in a single 2,400-line route file. The modular structure made it possible for two developers to work on reports and competitive-intel simultaneously without merge conflicts.
Cerbos for Fine-Grained RBAC
The legacy Express app had role checks scattered throughout route handlers: if (req.user.role === 'admin') { ... }. This made authorization logic impossible to audit and easy to get wrong. We replaced it entirely with Cerbos, an open-source authorization engine that evaluates policies defined in YAML.
Cerbos runs as a sidecar container alongside the NestJS API. The API sends authorization requests to Cerbos over gRPC, and Cerbos evaluates them against the policy files. This means authorization logic lives outside the application code, is version-controlled, and can be tested independently.
# policies/report_resource.yaml
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: default
resource: "report"
rules:
- actions: ["read"]
effect: EFFECT_ALLOW
roles:
- analyst
- manager
- admin
- actions: ["create", "update"]
effect: EFFECT_ALLOW
roles:
- analyst
- admin
condition:
match:
all:
of:
- expr: request.resource.attr.tenantId == request.principal.attr.tenantId
- actions: ["delete"]
effect: EFFECT_ALLOW
roles:
- admin
condition:
match:
all:
of:
- expr: request.resource.attr.tenantId == request.principal.attr.tenantId
- expr: request.resource.attr.status != "published"
- actions: ["export"]
effect: EFFECT_ALLOW
roles:
- manager
- admin
condition:
match:
expr: request.principal.attr.subscription == "enterprise"
This policy says: analysts and managers can read reports; analysts and admins can create and update reports but only within their own tenant; admins can delete reports only if they are unpublished and in their tenant; and only enterprise-tier managers and admins can export. That level of granularity would have required dozens of if-else blocks in the old codebase.
The NestJS integration uses a custom guard and decorator:
// cerbos-check.decorator.ts
export const CerbosCheck = (resource: string, action: string) =>
SetMetadata('cerbos', { resource, action });
// cerbos-policy.guard.ts
@Injectable()
export class CerbosPolicyGuard implements CanActivate {
constructor(
private reflector: Reflector,
private cerbosService: CerbosService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const { resource, action } = this.reflector.get('cerbos', context.getHandler());
const request = context.switchToHttp().getRequest();
const user = request.user;
const isAllowed = await this.cerbosService.check({
principal: {
id: user.userId,
roles: user.roles,
attr: {
tenantId: user.tenantId,
subscription: user.subscription,
},
},
resource: {
kind: resource,
id: request.params.id || 'new',
attr: request.body || {},
},
action,
});
if (!isAllowed) throw new ForbiddenException('Policy check failed');
return true;
}
}
// Usage in controller
@Get(':id')
@CerbosCheck('report', 'read')
@UseGuards(JwtAuthGuard, CerbosPolicyGuard)
async getReport(@Param('id') id: string) {
return this.reportsService.findOne(id);
}
Code Splitting and Bundle Optimization
The legacy app served a single 2.5MB JavaScript bundle on every page load. With Vite and React's lazy loading, we reduced the initial bundle to 380KB and loaded additional chunks on demand.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
// Vendor splitting
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-charts': ['recharts', 'd3'],
'vendor-table': ['@tanstack/react-table'],
'vendor-editor': ['@tiptap/react', '@tiptap/starter-kit'],
},
},
},
chunkSizeWarningLimit: 500,
},
});
// Route-level code splitting in React Router
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Reports = lazy(() => import('./pages/Reports'));
const CompetitiveIntel = lazy(() => import('./pages/CompetitiveIntel'));
const DrugPipeline = lazy(() => import('./pages/DrugPipeline'));
const PatentMonitor = lazy(() => import('./pages/PatentMonitor'));
function AppRoutes() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/reports/*" element={<Reports />} />
<Route path="/intel/*" element={<CompetitiveIntel />} />
<Route path="/pipeline" element={<DrugPipeline />} />
<Route path="/patents" element={<PatentMonitor />} />
</Routes>
</Suspense>
);
}
The manual chunks configuration separates heavy vendor libraries into their own bundles. Recharts and D3 (used on the analytics page) are never loaded on the dashboard. The TipTap editor (used in report creation) is never loaded on read-only pages. This alone cut the initial load from 2.5MB to under 400KB.
CI/CD Pipeline with Docker
We set up isolated environments for development, staging, and testing, each with its own Docker Compose stack and database. The CI pipeline runs on GitHub Actions and deploys through SSH.
# .github/workflows/deploy.yml
name: Deploy to Staging
on:
push:
branches: [develop]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: pharmaedge_test
POSTGRES_PASSWORD: test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
cerbos:
image: ghcr.io/cerbos/cerbos:latest
ports: ['3593:3593']
options: >-
--mount type=bind,source=${{ github.workspace }}/policies,target=/policies
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- run: pnpm install --frozen-lockfile
- run: pnpm run test:unit
- run: pnpm run test:e2e
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/pharmaedge_test
CERBOS_HOST: localhost:3593
build-and-deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker images
run: |
docker build -t pharmaedge-web:${{ github.sha }} -f apps/web/Dockerfile .
docker build -t pharmaedge-api:${{ github.sha }} -f apps/api/Dockerfile .
- name: Deploy to staging
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: deploy
key: ${{ secrets.SSH_KEY }}
script: |
cd /opt/pharmaedge
docker compose -f docker-compose.staging.yml pull
docker compose -f docker-compose.staging.yml up -d --remove-orphans
docker system prune -f
Measuring the Performance Gain
We did not just claim 85% improvement -- we measured it rigorously. Before migration, we ran Lighthouse audits on the five most-visited pages and recorded Web Vitals from real user monitoring (RUM) via a small script that reported to our analytics endpoint.
Here are the before and after numbers from production RUM data, averaged over 30 days post-migration:
| Metric | Before (Pug/Express) | After (React/NestJS) | Change |
|---|---|---|---|
| Page Load (p50) | 3.2s | 0.48s | -85% |
| Page Load (p95) | 6.8s | 1.1s | -84% |
| Time to Interactive | 4.1s | 0.9s | -78% |
| First Contentful Paint | 1.8s | 0.3s | -83% |
| Initial JS Bundle | 2.5MB | 380KB | -85% |
| Subsequent Navigation | 2.1s (full reload) | 120ms (SPA) | -94% |
The biggest gain was on subsequent navigation. In the Pug app, every click was a full page load. In the React SPA, navigating between pages fetches only the data via API calls while the shell stays mounted. That 2.1s to 120ms improvement fundamentally changed how the product felt.
Common Migration Pitfalls We Hit
Pitfall 1: Migrating routes without migrating their APIs. We initially tried to have React pages call the legacy Express API endpoints. The problem was that those endpoints returned HTML fragments and server-rendered data blobs, not clean JSON. We learned to migrate the API endpoint to NestJS at the same time as the frontend route.
Pitfall 2: CSS conflicts. The legacy app used Bootstrap 3 with heavily customized SCSS. The new React app used Tailwind CSS. When both apps loaded on the same domain, their styles clashed. We solved this by scoping the legacy CSS under a .legacy-app wrapper class and ensuring Tailwind used a prefix during the transition period.
Pitfall 3: State management assumptions. The Pug app relied heavily on server-side sessions. React components expected client-side state. We had to audit every page for hidden state dependencies -- things like a user's selected filters being stored in req.session rather than in the URL or local storage.
Pitfall 4: SEO regression. Moving from server-rendered Pug to a client-rendered SPA initially hurt our search engine visibility. We addressed this by implementing critical pages with server-side rendering in Next.js for the public-facing portions, while keeping the authenticated app as a pure SPA (search engines do not need to index authenticated dashboards).
Key Takeaways
The strangler fig pattern is the safest way to migrate a legacy application. It requires more upfront infrastructure work (the Nginx routing layer, shared authentication, parallel CI/CD pipelines), but it eliminates the biggest risk of a rewrite: the all-or-nothing launch. We migrated PharmaEdge.ai over four months, one route at a time, with zero downtime and zero data loss. The performance numbers validated the approach, but the real win was the team's ability to ship features faster on the new architecture -- what used to take two weeks in Pug/Express now takes three days in React/NestJS.
If you are staring at a legacy codebase and wondering whether to rewrite or refactor, consider the strangler fig. It is slower to start, but it is the approach that actually finishes.