Migrating JS from Yarn to NPM
August 14, 2026
In Ruby on Rails, the software ecosystem I predominantly work in these days, yarn has been the default package manager for the javascript side of things for a long time. (See my big write-up of the timeline & current state of frontend asset management in Rails).
When many of the apps I regularly work on were written, that was a pretty reasonable choice! Yarn had some solid advantages over npm generally, way back when (that's less obviously true now; npm works just fine these days, and has some advantages over yarn). Yarn was also very clearly "the Rails way", one relative constant in a JS-with-Rails landscape that often pretty inscrutable to Rails devs who weren't as immersed in the frontend tooling ecosystem that was evolving in javascript-land. For many serious Rails devs, doing something "the Rails way" means it is obviously the right way to do it unless you have a darn good reason. (In the Rails doctrine, "the menu is omakase" and one should prioritize convention over configuration.
Yarn continues to work pretty well for a lot of the projects we maintain, but as security vulnerability disclosure frequency and subsequent patching accelerates due to LLM-driven development, I am finding the lack of npm audit support in yarn really annoying.
npm audit fix is a lot nicer than merging a half-dozen individual dependabot PRs on the regular!
So, I am starting to migrate a few projects from using npm to yarn. (These projects are using one of the many ways that now exist to handle frontend assets in Rails that are not import maps, Rails 8's new default approach. Import Maps let you use javascript in Rails without using npm or yarn. But Rails 8 users may still find they need to think about npm vs yarn if they're not using import maps, like if they're using jsbundling-rails with any javascript bundling tool, or perhaps if they're still using shakapacker, the drop-in webpacker replacement for projects not ready to move to webpacker. Did I mention I have a big blog post covering this mess?)
The primary artifact in the repository that indicates what frontend package manager is in use on the project is the lockfile. For yarn, that's a file called yarn.lock, while for npm, that's package-lock.json. (Does your project have both? Go talk to your team, decide on one, delete the other file, and ensure the remaining file is up to date with a fresh [npm|yarn] install. Then commit that lockfile and don't let anyone on the team commit the wrong file again later!)
package.json specifies what packages your app needs; the lockfile keeps track of what exact versions are in use. package.json may specify that any minor or patch version is fine for a given package, and the lockfile specifies which exact version you were using in development, so that your staging and production servers can use that exact version, too, even if a minor or patch version comes out between when you work on your code and when it gets deployed.
When you do npm audit --fix, npm checks your package list against known vulnerabilities, and bumps the version in your lockfile to a patched version of any of those packages with vulnerabilities that have patched versions available that meet version range allowed by your package.json.
Unfortunately, automated tooling for moving from yarn to npm seems to be pretty sparse. There are some tools I could install and run to try to recreate an npm lockfile that contains exactly what was in my yarn.lock, but then I'm hitting a tradeoff: run a new tool I don't fully trust against the codebase I'm working on to regenerate the lockfile for the new format, or start from scratch, which may include version updates that weren't already in the app I'm migrating. (I could of course also piece together my lockfile from scratch, by hand or hoping an LLM will do a good job at it. I'd rather not do either of those.)
So, I went ahead and did:
rm yarn.lock
npm install
npm install creates a new package-lock.json if one doesn't already exist. If you stopped up above and noticed you do have both package-lock.json and yarn.lock in your repo, the reason is sometimes people working on your codebase do npm install when they should be doing yarn install or vice versa, and then they commit and merge in the new file that's generated rather than the changes that the install process would have made to the existing project lockfile.
Next, be sure to update any other references in your project to say npm instead of yarn, like in documentation or CI configurations. Ensure any yarn-specific commands or options are replaced with the equivalent from npm.
For instance, these two commands (in Yarn Classic and Yarn 2) should be replaced with npm ci in your CI configuration.
- yarn install --frozen-lockfile
- yarn install --immutable
+ npm ci
After re-installing your node modules using npm instead of yarn, of course you will need to test your frontend build (webpack, esbuild, whatever you have defined in the build script in your package.json).
Resolving transitive dependencies 🔗
A final gotcha to check for before you ship: are there any files with a transitive dependency on another dependency, that in your new lockfile, are not resolving to the same package?
I ran into this issue with a project that was using jquery and multiple packages containing jquery plugins. When I tested the build, I saw strange behavior that at first, I thought was related to Turbo Drive staying enabled when it was supposed to have been disabled. But that was a misleading symptom: really, Turbo wasn't getting disabled because there was a runtime error in the overall javascript bundle that was created, which stopped the rest of the bundle from being executed.
The errors were happening in code that ran automatically when a jquery plugin was imported, to initialize the jquery-add-on behavior. Because webpack hoists all imports to the top of the bundle, that initalization code appeared & ran in the bundle before the code that appeared to be later when looking at application.js.
When I realized that, I was able to see that the plugin code was trying to initalize itself on an instance of jquery that didn't actually exist in the global context of the browser javascript.
This was a consequence of how our freshly generated npm lockfile resovled jquery & the plugins. The package.json allowed jquery to bump to a newer major version, but the plugins still needed an older version. So, the npm install ended up pulling in the latest version of jquery, which was resolved from import 'jquery' and an older version to satisfy the transitive dependencies of each of the plugins. Webpack wasn't tying all those packages together to refer to the same instance of jquery when they were imported.
There are a couple possible fixes here:
- making webpack resolve jquery in the same way each time, despite multiple versions of jquery existing in
node_modules - or, fixing the issue with additional config in
package.jsonso that we can leave our webpack config alone, so that we won't need any extra setup if we ever change bundlers away from webpack. - Tighten up the jquery version range specified in our
package.jsonso that we can't ever use a different version than the jquery plugins allow.
For fix 1, in webpack's config, we would add this code:
const options = {
resolve: {
// Force a single jQuery instance. npm nests a separate jquery 4.x copy under each
// jquery plugin package, so the plugin and its extensions otherwise register
// against different jQuery objects and crash on load.
alias: {
jquery: path.resolve(__dirname, '../../node_modules/jquery')
},
For fix 2, in package.json, we just need this:
"overrides": {
"jquery": "$jquery"
},
But how can I tell if I will have this problem, prior to runtime?? 🔗
You can run npm ls --depth=2 (after npm installing) and you should see a whole lot of deduped in the output for dependecies that are shared between packages you're using.
Before this issue was fixed, doing so showed that the two jquery plugins in our project were using a different version of jquery, not a deduped one that was the version we listed explicitly ourselves in package.json.