A journey rewrote entire codebase to ESM
I thought swapping Rollup → typescript compiler with pure ESM would take a weekend. AI wrote 98% of the code in one day. The last 2% ate three months of my life. Here's every gotcha, every stupid mistake, and exact strategy that finally worked so you don’t have to suffer the same way.
Over the past few months I have been working on an EMS migration task, moving away from bundler just using the TypeScript compiler. If you are ever tackled something similar, you must read this guide. On paper it looks dead simple, right? Just tweak a few config files and fix some syntax. That’s exactly what I thought too.
Reality hit me like a truck.
This wasn’t “just swapping one library.” It was a 50,000+ line monorepo where every package is tightly coupled with everything else. One wrong move on a dependency and the whole thing spirals into chaos. I went down so many dead ends it wasn’t even funny. So yeah, if you’re about to touch ESM migration, please save yourself the pain and read that guide properly. Trust me, you don’t want the same headaches I had.
It is how being started
When AI coding tools exploded, I went all in, from Cursor to Claude Code, watching every new coding LLM drop, each one spitting out better and better results, making me more and more confident that I could just throw anything at it and get gold back.
At some point I genuinely believed AI could solve literally every problem. If the output was garbage, it just meant my prompt sucked or I didn’t give it enough context. Simple “context engineering” issue.
So when ESM migration task landed on my desk, that’s exactly what I did: opened Claude Code told it “convert everything to pure ESM + tsc only, no more bundler, go.
Roughly 98% of the changes done. Tens of thousands of lines “converted.” I was strutting around the office like I’d just invented fire.
Then the remaining 2% kicked my ass for the next three months straight.
Switch to ESM what actually means in a Giant Monorepo
This thing is a massive monorepo, 80+ internal packages, 50k+ lines, and a dependency graph that looks like a bowl of spaghetti. One package breaks and good luck figuring out which one is the real culprit, the error messages will point you to ten different places at once.
Like most devs, I only ever touched my little corner of the codebase. I knew my packages, maybe the ones directly above or below me, but the rest? Complete mystery. So when something deep in the graph started failing, I was totally lost.
Even when I thought I finally understood the problem, I’d run the full build or the tests and… everything on fire again. Fix one thing → rebuild → new error → revert → cry → repeat. That cycle alone ate two solid months.
The absolute killer though? When the monorepo build finally turned green locally, I’d publish the packages and wire them into the actual application… and boom, new set of explosions. Go back, fix, republish, try again. Endless loop of pain.
Moral of the story: on any huge task, split it into the smallest possible pieces. Yeah yeah, everyone says that, I know. But why the hell didn’t I do it from day one?
I’d never done anything even close to this scale before. My previous “big migrations” were tiny Rollup builds in single packages . And because I was riding the AI high, I thought “let me just ask Claude to do the whole thing as a proof of concept, then I’ll clean up the rest later.” Classic “build the car before inventing the wheel” move.
It was an absolutely terrible idea. Changing everything at once in a tightly coupled monorepo is how you create few months of pure suffering. Don’t do it.
The Gotchas That No Blog Mentions
1. Make sure every packages entry points actually compiler output
This one bit me more times than I can count.
AI is great for the boring repetitive stuff, let it rewrite files. But never trust it 100%. Do it package by package, unless two are so tangled you literally can’t separate them, but it only happened once or twice.
After a package finally builds, don’t run tests yet. Just open /dist folder and stare at it like you are looking for bombs.
99% of the “Cannot find module” errors that only blow up after you publish and use the package in the real app? They come from package.json pointing at files that simply don’t exist. One typo, one missing folder, one forgotten .d.ts and everything green locally, but after you published the package, is broke.
Real example: your compiler output should match your package.json
/dist
/hooks
/server
...
index.ts
/provider
index.ts///package.json
...
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"default": "./dist/server/index.js"
}2. Just upgrade external packages
You will hit some errors that clearly come from node_modules. First instinct, open Claude and waste half a day trying to patch the library.
Stop. Just upgrade dependency.
For some old packages in your codebase, they are probably outdated with the latest updated packages. Most of the time latest version already supports proper ESM. Yeah, you might hit some breaking changes, but it’s still way faster than trying to keep a 5 year old version alive. And you can just use Claude Code to fix breaking changes in hours now.
Only when upgrading really won’t fix it, then try the stupid import tricks. The one that actually worked a few times (e.g. clsx) allows it. In an ESM setup it requires explicit matching for any package, that will fix any failed import.
// this breaks
import clsx from 'clsx';
// and this works
import { clsx } from 'clsx';And while we are talking external packages, this migration is the perfect excuse to finally upgrade ESLint and Prettier.
Your old ESLint will throw fake errors on perfectly valid syntax, especially this one:
import packageJson from './package.json' with {type: 'json'};Just upgrade to ESLint with latest @typescript-eslint and Prettier. One day of config pain, zero ghost errors for the rest of the migration.
3. Clean up your compiler output, don’t ship garbage
Everything builds, CI green, app works… cool. Now open /dist folder and look what you actually published. You do NOT want your test files, mocks, stories, webpack configs, random .ts files, or any other crap in there. Nothing breaks, but it looks like shit and bloats the package.
Fix it in tsconfig first:
{
...
"exclude": [
"webpack.config.cjs",
"**/*.spec.*",
"**/*.test.*",
"**/*.stories.*",
...
]
}
Some packages also have static assets (public folder, images, json fixtures, whatever) that tsc completely ignores. You need a tiny post-build script to copy them over. Here’s the one I ended up using:
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function copyFolder(source, target) {
if (!fs.existsSync(source)) return console.log(`⚠️ ${source} not found, skipping`);
fs.mkdirSync(target, { recursive: true });
for (const item of fs.readdirSync(source)) {
const src = path.join(source, item);
const dest = path.join(target, item);
if (fs.statSync(src).isDirectory()) {
copyFolder(src, dest);
} else if (!src.endsWith('.ts')) {
fs.copyFileSync(src, dest);
}
}
}
// Copy whatever static folders you need
const assetsToCopy = [
{ from: '../src/assets', to: '../dist/src/assets' },
{ from: '../public', to: '../dist/public' },
// add more if you have them
];
for (const { from, to } of assetsToCopy) {
const srcPath = path.resolve(__dirname, from);
const destPath = path.resolve(__dirname, to);
copyFolder(srcPath, destPath);
}
console.log('✅ Assets copied');
Then in package.json:
{
"scripts": {
"build": "tsc && node copy-assets.js"
}
}Final Thoughts
Yeah, your AI chat buddy will blast through most of the mechanical changes in a day or two. It will rewrite imports, spit out “exports” maps, rename files, and make everything look perfect. Do NOT believe it.
You will still have to get your hands dirty, read errors, open compiler output folders, and sometimes just stare at the screen for hours. AI is an insane accelerator, not a magician.
Before you let the model loose on the whole monorepo, spend one evening reading these three pages properly. Bookmark them. Keep them open the entire time. They will save you weeks:
And of course the guide you already know - ESM migration guide