Modernizing an outdated TypeScript-based monorepo can be a complex and sometimes overwhelming process. This guide outlines a pragmatic step-by-step approach to upgrade tools and runtime environments, addressing common pain points along the way.
While no two projects are the same, the strategies outlined here offer a framework you can adapt to your own needs. By the end, you’ll have a better understanding of how to approach codebase modernization and make incremental improvements that set your projects on a more maintainable path.
1. Understanding the Starting Point
The codebase is a Typescript-based monorepo. Started in December 2020, the codebase remained untouched since november 2022. Over time, this once-modern stack became a maze of outdated libraries and configurations.
The monorepo contained 4 packages:
api: a GraphQL Koa based API
report-generator: a Puppeteer-based worker for PDF generation
model: a shared modules for GraphQL contracts and utilities
front: a React frontend built with Create React App.
Dependency management was handled using Yarn 1, bloated with nohoist and selective dependency resolutions that made updates daunting.
Cloc give use some useful information about the project size
cloc --vcs git .
Language | Files | Blank | Comment | Code |
---|---|---|---|---|
TypeScript | 560 | 3304 | 380 | 4 0623 |
JSON | 27 | 34 | 0 | 4 131 |
GraphQL | 31 | 257 | 9 | 2 044 |
Markdown | 21 | 548 | 0 | 1 247 |
YAML | 16 | 101 | 21 | 7 18 |
SQL | 50 | 295 | 351 | 5 44 |
Prisma Schema | 1 | 49 | 0 | 4 39 |
XML/Shell/HTML/ ... | 31 | 58 | 39 | 8 32 |
SUM | 738 | 4 646 | 800 | 50 580 |
Despite its manageable size (50 580 LOC), the codebase has been dormant since 2 years. Last commit was in november 2022. Several crucial components have become outdated:
Create React App is no longer actively maintained
Node.js 18 has reached end-of-life
Many libraries are outdated for more than 4 major versions and have moved toward ECMAScript Modules (ESM), leaving the monorepo incompatible with modern tooling.
Let's have a look on test coverage by running the yarn test:all
command
Package | api | report-generator | model | front | SUM |
---|---|---|---|---|---|
Test cases | 14 | 0 | 3 | 23 | 40 |
With a total of 40 test cases for more than 50 000 LOC. Automated tests won't provide us security during the migration process 😥.
Why upgrade?
With these limitations in mind, upgrading is not a luxury but a necessity. Doing so will not only bring the project up to date with modern development practices but also ensure security, better developer experience, and compatibility with modern tooling
2. Migration Path: Step-by-Step
Step 1: Migrate Package Manager to pnpm
The first step in modernizing the codebase was to replace Yarn 1 with pnpm. This decision was driven by pnpm’s unique approach to dependency management. Unlike Yarn’s flat node_modules
structure, PNPM uses a non-flat structure, which prevents undeclared dependencies from being accessed inadvertently. This feature alone helps to resolve many transitive dependency issues, allowing us to remove complex nohoist
configurations and dependency resolutions
.
Additionally, pnpm’s powerful built-in workspace support simplifies monorepo management.
Migrating was straightforward: we removed the existing Yarn configurations, initialized pnpm workspaces, adjusted scripts for compatibility, and installed all missing explicit dependencies in each packages.
By leveraging its efficient disk usage and faster installation times, developers experience a significant boost in productivity.
Step 2: Upgrade Node.js

With the package manager upgraded, the next step was to update the runtime environment. The codebase was on Node.js 18, which has now reached its end-of-life. Upgrading to Node.js 22, the current Long-Term Support (LTS) version, brought several benefits.
Apart from improved security and performance, Node.js 22 offers better ESM support, a critical feature for the next phase of the migration.
This upgrade involved updating CI/CD pipelines, checking dependency compatibility, and refactoring deprecated APIs. These steps ensured a seamless transition to a more modern runtime without breaking the existing functionality.
Step 3: Transition from CommonJS to ECMAScript Modules (ESM)
Transitioning from CommonJS (CJS) to ESM was perhaps the most challenging yet transformative step. As modern libraries increasingly drop support for CJS to focus on ESM, making this shift was unavoidable.
The migration began with updating the TypeScript configuration
{"compilerOptions": {"module": "preserve", // We use external tools for transpiling"moduleResolution": "Node16",// ...}//...}
To learn more about tsconfig.json
, take a look at: https://www.totaltypescript.com/tsconfig-cheat-sheet
Migrating to ESM requires updates to the package.json
file to correctly define module types and entry points. Below, we provide an example of a package.json
before and after ESM migration. Key adjustments include specifying "type": "module" and using updated entry point fields such as exports
. For more details on configuring package entry points, refer to the official Node.js documentation: Package Entry Points.
{"name": "@myapp/model","private": true,"author": "Kevin Pennarun",- "main": "dist/index.js",- "source": "src/index.ts",- "typings": "dist/index.d.ts",- "scripts":{+ "type": "module",+ "exports": {+ "types": "./dist/myapp-model.d.ts",+ "import": "./dist/myapp-model.mjs",+ "require": "./dist/myapp-model.umd.js"+ },+ "files": [+ "dist"+ ],+ "scripts": {"graphql:codegen": "graphql-codegen --config codegen.yml",- "build": "yarn graphql:codegen && tsc"+ "build": "pnpm graphql:codegen && vite build"},"dependencies":{// ...},"devDependencies" :{// ...}}
A significant effort went into refactoring imports across the codebase to include explicit .js
extensions, a requirement for ESM. To automate this tedious task, we used Biome, a modern tool that combines linting and formatting with native TypeScript support. With a single command, Biome fixed thousands of import paths, making the transition faster and less error-prone.
We just need to add the useImportExtensions and run biome lint . --unsafe --write
and all needed extension are added.
A lot of time and effort was dedicated to upgrade many librairies to their last version to ensure being up to date and maintainable and having a good ESM support.
Simultaneously, we replaced outdated build tools with modern alternatives. The frontend transitioned from Create React App to Vite, powered by the highly efficient esbuild. Jest was swapped for Vitest, which better aligns with modern ESM workflows.
For the GraphQL API, we introduced SWC, a blazing-fast compiler that dramatically improved build times.
3. Outcome: A Modernized Tech Stack
The modernization effort culminated in a completely updated tech stack. Here’s a snapshot of the transformation:
Aspect | Before | After |
---|---|---|
Node.js | 18 (EOL) | 22 (LTS) |
Package Manager | Yarn 1 | PNPM 9 |
Modules | CommonJS | ESM |
Linting | ESLint | Biome |
Frontend | Create React App | Vite |
These upgrades have significantly enhanced the developer experience, streamlined dependency management, and ensured compatibility with modern tools and libraries.
Let's quickly evaluate a potential performance gain provided by the codebase upgrade.
With the help of k6, we can easily run a load test on the old and new codebase and compare the results.
The test will be very simple and does not cover all the edge cases, but it should give us a rough idea of the performance gain.
We will run the test with 10 000 virtual users trying to login for 30 seconds.
import http from "k6/http";import { sleep } from "k6";import { config } from "./config";export const options = {vus: 10_000,duration: "30s",};export default function () {http.post(`${config.baseUrl}/auth/login`, {email: config.userEmail,password: config.userPassword,});check(res, {"is status 200": (r) => r.status === 200,});sleep(1);}
Here is the output for the old codebase:
✗ is status 200↳ 87% — ✓ 46934 / ✗ 6903checks.....................: 87.17% 46934 out of 53837data_received..............: 41 MB 936 kB/sdata_sent..................: 10 MB 229 kB/shttp_reqs..................: 53837 1230.597694/siteration_duration.........: avg=5.79s min=1s med=1.54s max=42.5s p(90)=31s p(95)=31siterations.................: 53837 1230.597694/s
And here is the output for the new codebase:
✗ is status 200↳ 98% — ✓ 77679 / ✗ 908checks.....................: 98.84% 77679 out of 78587data_received..............: 68 MB 1.1 MB/sdata_sent..................: 17 MB 277 kB/shttp_reqs..................: 78587 1309.607814/siteration_duration.........: avg=3.92s min=1.35s med=2.58s max=56.41s p(90)=3.16s p(95)=13.24siterations.................: 78587 1309.607814/s
There is significant improvement in the new codebase:
Total requests increased by ~46%
P(95) time decreased from 31s to 13.24s. That is 134% improvement
Success rate increased from 87% to 98%
4. Key Learnings
Throughout this journey, several key insights emerged:
Modern Tools Save Time: Tools like PNPM and Biome simplify complex tasks, saving hours of manual effort. PNPM’s strict dependency rules ensure consistency across packages, while Biome’s seamless integration with TypeScript eliminates the need for configuring multiple linting plugins.
Compatibility Matters: ESM adoption is inevitable. Transitioning early prevents future roadblocks and ensures access to the latest library features.
Testing is Critical: The absence of comprehensive integration tests slowed progress and introduced regressions. Investing in a robust testing suite is crucial for minimizing risk during large-scale migrations.
Codebase update must be continuous: A codebase is never a static asset. It evolves alongside the tools, dependencies, and technologies it relies on. Allowing a project to stagnate can lead to compounding technical debt, making future updates significantly more difficult and time-consuming. Regularly updating dependencies, frameworks, and runtime environments not only ensures compatibility and security but also enables teams to take advantage of performance improvements and new features. By adopting a mindset of continuous maintenance, you can mitigate risks, reduce upgrade complexity, and keep your codebase aligned with industry standards. Modernizing shouldn’t be a one-time effort but rather an ongoing process woven into your development workflow.
Modernizing a codebase abandoned for two years is no small feat. This effort required 525 file changes, with 27 712 lines of code added and 31 125 lines deleted. Despite the challenges, the result is a leaner, more maintainable foundation for future growth.
