Logo
← Back to Blog

A journey rewrote entire codebase to ESM

I thought moving from Rollup to the plain TypeScript compiler with pure ESM would take a weekend. Claude Code wrote 98% of the changes in a day, and the last 2% took me three months. Here is what I learned about the migration itself, and about how to actually work with an AI agent on a task this size.

Over the past few months I have been working on an ESM migration, moving a monorepo away from bundlers and building everything with the TypeScript compiler only. If you are about to do something similar, start with this guide. On paper it looks simple: tweak a few config files, fix some import syntax. That is exactly what I thought too, and it turned out to be one of the hardest tasks I have done in years.

This post is really two stories in one. It is a migration write-up, with the gotchas that no blog post mentions. It is also a story about working with Claude Code on a large task — what worked, what went wrong, and the workflow I ended up with.

How it started

The first time I tried agentic programming was with Cursor. It was impressive how the agent could fill in code and often resolve problems in ways I wouldn't have thought of. Those experiences were mostly with small, specific tasks. On more complex problems, Cursor often generated nonsense code that cost more time to fix than it saved, so I didn't fully believe in it — maybe it was just marketing?

Then I started seeing developers on social media sharing success stories about Claude Code, and that caught my interest. I've grown to love it since, and now I use it daily. Somewhere along the way my confidence turned into something else: I genuinely believed AI could solve every problem, and if the output was garbage, it just meant my prompt was unclear or I didn't give it enough context. A "context engineering" issue, nothing more.

So when the ESM migration landed on my desk, that is exactly what I did. I opened Claude Code and told it: convert everything to pure ESM, TypeScript compiler only, no more bundler. Go.

Roughly 98% of the changes were done. Tens of thousands of lines converted in a day. I was very pleased with myself.

The remaining 2% took the next three months.

What "switch to ESM" actually means in a giant monorepo

The project is a large monorepo: 80+ internal packages, 50k+ lines, and a dependency graph where everything is coupled with everything else. When one package breaks, the error messages point to ten different places at once, and good luck figuring out which one is the real culprit.

Like most developers, I had only ever touched my own corner of the codebase. I knew my packages, and maybe the ones directly above or below me. The rest was a mystery. So when something deep in the graph started failing, I was lost.

Even when I thought I finally understood a problem, I would run the full build or the tests and everything was broken again. Fix one thing, rebuild, hit a new error, revert, try again. That cycle alone took two solid months. And the worst part came after: when the monorepo build finally went green locally, I would publish the packages, wire them into the actual application, and get a whole new set of failures. Go back, fix, republish, retry.

The lesson is nothing new: on any huge task, split it into the smallest possible pieces. Everyone says that. I still didn't do it from day one, partly because I had never done anything at this scale — my previous "big migrations" were Rollup configs in single packages — and partly because I was riding high on AI confidence. I thought I could let Claude do the whole thing as a proof of concept and clean up the rest later. Changing everything at once in a tightly coupled monorepo was a terrible idea, and it cost me months.

What I learned about working with Claude Code

Almost all of the changes in this migration were written by Claude Code. I personally didn't have much experience with ESM or TypeScript compiler configuration, and most of my time went into context engineering and prompt engineering to make the workflow workable. Here is what actually mattered.

Context engineering

My experience with LLMs is that when you ask them to solve a complicated problem — or a problem you don't fully understand yourself — they give incorrect answers most of the time. Sometimes the model lacks the training data; sometimes the prompt was unclear. Either way, the results are inconsistent. I came across the term "context engineering" in an Andrej Karpathy tweet, and it changed how I get results out of these tools:

  • Create a CLAUDE.md memory file. It sets boundaries for the tool, defines what it should and shouldn't do, and gives standing instructions to the agent.
  • Enable MCP servers so Claude Code can access up-to-date information — for example Context7, or a UI library MCP like Shadcn-UI.
  • Use folder structure deliberately: log files, research files, plan files. It helps the tool stay on track with what it should do and what has already been done.

Never use a sub-agent to write code

Never use a sub-agent to write code. Never use a sub-agent to write code.

Each sub-agent has its own context window, separate from the main conversation. If you pass a coding task to a sub-agent, it costs a ton of tokens and you gain nothing, because the sub-agent doesn't have the full picture of the feature or the history from the primary agent. Use sub-agents for research tasks, save the findings to a .md file, and let the primary agent do the coding.

Workflow attempt #1: a full life-cycle team — higher risk

My first attempt was to mimic how a real team ships software. I built a full life-cycle workflow: dev, UAT, software architect, code reviewer — each one a sub-agent with its own instructions, chained together through CLAUDE.md:

  1. 01
    primary orchestrator · @researcher or @architectExplore phase
  2. 02
    primary orchestrator · @architectPlan phase
  3. 03
    @ai-engineer, @software-developer, @frontend-developer …Execution phase
  4. 04
    @code-reviewerCode reviewfail ↩ back to 03 · execution
  5. 05
    @architect-reviewerArchitecture reviewfail ↩ back to 02 · plan
  6. 06
    @product-ownerTesting phasefail ↩ back to 03 · execution
  7. 07
    primary orchestratorCompletion and merge
The full life-cycle workflow: every phase delegated, failures loop back up the chain.

It looks very close to a day-to-day business setup, and it is not useful.

With that many layers, if one agent goes slightly wrong, the whole workflow blows up. And if the prompt at the very start was ambiguous, every phase after it amplifies the ambiguity, and the whole result turns into a nightmare. I watched an agent hard-code a test case just to satisfy a requirement. I watched a very complicated architecture get bolted onto a simple feature, full of logic nobody asked for. You end up with a result that misses the actual requirement, and then you have to go back and fix it anyway.

I suspect a complicated workflow pushes the agent toward "get it done" instead of "get it correct" — and with a long context history, it can simply forget what the primary goal was.

Workflow attempt #2: supervised — lower risk

What worked was a much simpler loop. Given that there is always a chance the AI goes wrong, you need to leave room for intervention before a problem gets bigger. Instead of letting the agent code everything, tell it how the code should be written. Break the task into small pieces, let the tool build one piece, then examine it yourself before moving to the next. If things break, open a new request and let the tool help fix it. Your job as the developer is to oversee how the agent performs and give it direct guidance.

  1. 01
    @research agentResearch & analysiscompile research → update session
  2. 02
    sub-agents only for gaps · e.g. @software-developerImplementation planning
  3. 03
    @code-reviewer mandatory · research sub-agents optionalImplementation & reviewcode review fail ↩ address feedback and repeat
The supervised workflow: three phases, sub-agents for research only.

Notice where the sub-agents sit in this version: research and planning support only. The primary agent writes all the code, and there is a checkpoint between every phase where I look at the output before anything moves forward.

Here is a real prompt from the migration, fixing one package at a time:

Start new session: ui_lib_esm_fix_20250818_001
Task: Complete ESM migration for UI design system by fixing remaining
configuration and import issues
 
Requirements:
- Target directory: `packages/ui/design-system`
- Fix all remaining ESM-related issues from previous migration attempt
- Ensure all build, test, and development commands work without errors
 
Import Path Requirements:
- Use relative file paths for ALL local imports within the package
- Exception: files not processed by the TypeScript compiler (e.g. Cypress tests)
- Maintain proper ESM import/export syntax throughout the codebase
- Remove any CommonJS require() statements if present
 
Configuration Audit & Fixes — verify and fix ESM configuration for:
1. tsconfig.json — module resolution, target, and ESM settings
2. .eslintrc — ESM parsing and import rules
3. webpack.config — ESM module handling and output format
4. .prettierrc — consistent formatting for ESM syntax
5. Storybook configuration — ESM compatibility for .storybook/ files
6. figmagic configuration — research whether figmagic can generate
   ESM-compatible tokens, implement if possible
7. package.json — type: "module", exports field, scripts compatibility
8. Any other configuration files that may affect ESM compatibility
 
Command Validation — ALL of the following must execute without errors:
- pnpm build
- pnpm type-check
- pnpm cypress run --component --browser electron
- pnpm storybook
- pnpm build-storybook
- pnpm figmagic
- pnpm lint
 
Success Criteria:
- All configuration files properly set for ESM
- All local imports use relative paths
- Zero errors from all required pnpm commands
- Design system fully functional in ESM mode

The exact wording doesn't matter. What matters is that the task is small (one package), the requirements are explicit, and there is a concrete list of commands that must pass. That is what "supervised" means in practice — and it is the workflow I wish I had started with instead of "convert everything, go."

The gotchas that no blog mentions

1. Make sure every package's entry points match the compiler output

This one bit me more times than I can count.

AI is great at the boring, repetitive rewrites, so let it rewrite the files. But don't trust the result blindly, and go package by package — unless two packages are so tangled you genuinely can't separate them, which happened to me maybe twice.

After a package builds, don't run the tests yet. Open the /dist folder first and check what is actually in there.

In my experience, most of the "Cannot find module" errors that only show up after you publish and use the package in the real app come from package.json pointing at files that don't exist. One typo, one missing folder, one forgotten .d.ts, and everything is green locally but broken after publish.

A 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. Upgrade external packages instead of patching them

You will hit errors that clearly come from node_modules. My first instinct was always to open Claude Code and spend half a day trying to patch the library.

Don't. Just upgrade the dependency.

Old packages in your codebase are probably several major versions behind, and most of the time the latest version already supports proper ESM. Yes, you might hit breaking changes, but fixing those is still much faster than keeping a five-year-old version alive — and Claude Code can walk through breaking changes in hours now.

Only when upgrading genuinely doesn't help, try the import tricks. The one that worked for me a few times (clsx, for example) is switching to the named export — in an ESM setup the import has to match what the package actually exports:

// this breaks
import clsx from 'clsx';
 
// and this works
import { clsx } from 'clsx';

While we're on external packages: this migration is also the right moment to finally upgrade ESLint and Prettier. Old ESLint versions throw false errors on perfectly valid syntax, especially this one:

import packageJson from './package.json' with {type: 'json'};

Upgrade to the latest ESLint with @typescript-eslint and Prettier. One day of config pain, and no ghost errors for the rest of the migration.

3. Clean up the compiler output — don't ship garbage

Everything builds, CI is green, the app works. Now open the /dist folder and look at what you are actually publishing.

You don't want test files, mocks, stories, webpack configs, or stray .ts files in there. Nothing breaks, but it bloats the package and it isn't something you want users of your package to find.

Fix it in tsconfig first:

 {
  ...
  "exclude": [
    "webpack.config.cjs",
    "**/*.spec.*",
    "**/*.test.*",
    "**/*.stories.*",
    ...
  ]
 }
 

Some packages also have static assets (a public folder, images, JSON fixtures) that tsc completely ignores. You need a tiny post-build script to copy them over. Here is 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

Claude Code wrote almost all of the mechanical changes in this migration — the imports, the exports maps, the renamed files. It made everything look done. It was not done. I still had to read every error, open compiler output folders, and sometimes just sit with a problem for hours. AI is a serious accelerator, but it is not a magician, and on a task like this your job shifts from writing the code to supervising it.

There are still unknowns for me about how to set these workflows up properly. Most of what I learned came from the developer community rather than official docs, so my setup might work for this task and not for yours. But two things I am sure about: break the work into the smallest pieces you can, and before you let the model loose on the whole monorepo, spend one evening with these pages. Keep them open the entire time — they will save you weeks:

And of course the guide you already know — the ESM migration guide.