Fourteen Months in the Trenches: Migrating a Massive App from Nuxt 2 to Nuxt 3
A war story from a fourteen-month Nuxt 2 to Nuxt 3 migration on a large real-estate CRM. The silent runtime failures, the merge-conflict grind, and the lessons about scope, patterns, and trusting nothing but a real build.
Fourteen Months in the Trenches: Migrating a Massive App from Nuxt 2 to Nuxt 3
Every engineering team has a conversation they keep putting off. Ours was the one where somebody finally said out loud what we'd all been dodging: "We have to move off Nuxt 2."
Vue 2 had hit end-of-life. The ecosystem moved on without us. Our dependencies started shipping versions that assumed Vue 3, and every npm install felt like defusing a bomb. We were maintaining a large real-estate CRM: hundreds of components, a sprawling Vuex store, years of accumulated patterns, and a codebase that thousands of agents used every day to run their businesses. This was never going to be a weekend npx nuxi upgrade and a celebratory tweet. It was going to hurt.
It took us about fourteen months. This is the story of what broke, what I underestimated, and what I'd tell my past self before we started.
It's not an upgrade. It's a rebuild.
I went in with the wrong mental model. In my head, "Nuxt 2 to Nuxt 3" sat in the same bucket as "React 17 to 18" or "bump the minor version and clear the deprecation warnings." A migration. A weekend of yak-shaving.
That's not what it is. Nuxt 3 is a different framework that happens to share a name and a philosophy with its predecessor. Underneath it, Vue 2 became Vue 3: a reactivity system rewritten from scratch on Proxies, a component model with breaking changes in how props and events flow, and a build system swapped from webpack to Vite. Every one of those layers had its own set of landmines, and they all went off at the same time.
It clicked for me when I realized we couldn't do this incrementally in place. There's no "Nuxt 2.5 bridge" that lets you run half your app on the old engine and half on the new one. You fork a long-lived branch, you start dragging the whole codebase across the chasm, and you hope the chasm doesn't get wider while you're crossing it.
It got wider.
Struggle #1: The long-lived branch and the merge conflicts from hell
Here's the soul-crushing tension of a migration this size: the business does not stop. While a couple of us were heads-down rewriting the foundation on a nuxt-stage branch, the rest of the team kept shipping features to the production stage branch every day. Bug fixes, new onboarding flows, feature-flag rollouts, all landing on the exact files we were rewriting.
So every week we merged stage back into our migration branch, and every week it was a bloodbath. I have commits in my history with messages like "resolve merge conflicts with the nuxt upgrade branch" and "Merge stage into milestone-4-qa-new-merge-attempt." The word "attempt" in that second one is carrying a lot of weight. Some of those merges took an entire day. You'd resolve a conflict in a component, then realize the version on stage had been refactored to use a pattern that didn't even exist in Nuxt 3, so "resolving the conflict" really meant re-migrating the file from scratch.
What I'd tell my past self: merge from the mainline as often as you can stand it, daily if you can manage it. The pain of a merge scales super-linearly with how long you let the branches drift apart. A conflict caught after two days is an annoyance. A conflict caught after two weeks is a research project. We learned this the hard way and eventually built a discipline around it, but the first few months were run on hope, and hope is not a merge strategy.
Struggle #2: The global event bus, our old faithful, had to die
In Vue 2 we leaned hard on a global event bus. You know the pattern: a shared Vue instance, and components anywhere in the tree firing Bus.$emit('something-happened') while some distant component listened with Bus.$on. It was convenient. It was everywhere. And it was gone in Vue 3, because $on, $off, and $once were removed from the instance API entirely.
This was one of the nastier problems, because it doesn't fail at build time. It fails silently at runtime. A button gets clicked, an event fires into the void, nobody's listening, and a modal just... doesn't open. No error, no stack trace. Just a feature that stopped working, discovered three weeks later by a confused user.
We had to hunt down every Bus.$emit and $on in the codebase and rethink the actual data flow. Most of the time the honest answer was that the event bus had been a shortcut around thinking about component relationships. The fix was props-down / events-up for parent-child, and provide/inject for the deep cross-tree cases. It was more code, but it was better code. Suddenly you could trace where a piece of state actually came from. Migrating the call sites one ticket at a time, while making sure we hadn't missed a listener somewhere, took months and a lot of manual QA.
Struggle #3: v-model changed its contract, and it's everywhere
If you want to understand the sheer volume problem of a Vue migration, look at v-model. In Vue 2, a custom component that supported v-model used value as the prop and emitted input. If you wanted a different prop name, you declared a model option:
// Vue 2
export default {
model: { prop: 'selected', event: 'change' },
props: { selected: { /* ... */ } },
}
In Vue 3, that whole model option is gone. v-model now binds modelValue and emits update:modelValue, and named models use a cleaner v-model:selected syntax at the call site:
<!-- Vue 2 -->
<ContactSelect v-model="localContact" />
<!-- Vue 3 -->
<ContactSelect v-model:selected="localContact" />
That looks trivial in isolation. Now multiply it across every custom form control, every select, every date picker, every combobox, and every single place any of those components is used. Get one side right and the other side wrong, and you get a component that renders perfectly and updates nothing. We spent a long time chasing inputs that looked fine but silently dropped every keystroke.
Struggle #4: Proxies are not your old reactive objects
Vue 3's reactivity is built on JavaScript Proxies, and that abstraction leaks in ways that will ruin an afternoon.
My favorite bug from the whole project: a component in the onboarding flow started throwing a cryptic error, and it traced back to one innocent-looking line that did structuredClone(someReactiveObject). In Vue 2, that object was a plain object and structuredClone was happy. In Vue 3, that object is a reactive Proxy, and structuredClone, being a native browser API that knows nothing about Vue, chokes on it because Proxies aren't structured-cloneable. The fix was almost comically small:
// Blows up: structuredClone can't clone a reactive Proxy
const copy = structuredClone(this.formData);
// Works: spread produces a plain object
const copy = { ...this.formData };
But finding it was the hard part, and this class of bug kept showing up. The value is technically there, but it's wrapped in something that behaves differently than you assume. Vue.set and Vue.delete were gone too (you don't need them anymore, since Proxies track new keys natively), but every place we'd used them was now either dead code or subtly wrong. Reactivity that "just worked" in the old mental model had to be re-examined line by line.
Struggle #5: No more auto-imports, and the great explicit-import slog
Nuxt 2 auto-imported our components. You could drop a <UserCard /> into a template and Nuxt would find and wire up the file for you. It felt like magic. We turned that magic off on purpose for the Nuxt 3 build and moved to explicit imports everywhere: every component, every composable, every util.
This was a lot of tedious, mechanical work, and it produced enormous, noisy diffs where half the changes were just import statements getting alphabetized and reshuffled. But it paid off. Explicit imports meant the build could actually tell us when something was missing, tree-shaking got better, and new engineers could follow a component's dependencies without guessing which of 400 auto-registered globals a tag referred to. Tedious, but the right call.
Struggle #6: Everything in the toolchain moved at once
Because the framework rewrite forced a dependency reckoning, we ended up migrating half our stack in the same window:
- Mixins to composables. Our shared logic lived in Vue mixins, which are on their way out. We rewrote them as composables:
useContactPageQuery,useContactTimeline,useContactInsights, and dozens more. This wasn't find-and-replace. A good composable has a different shape than a mixin, with explicit inputs and returns instead of a magically mergedthis. - Vuex to data-fetching composables. A big chunk of my work was ripping data-fetching logic out of the Vuex store and moving it into query composables backed by a proper caching layer. We deleted whole swaths of hand-rolled prefetch logic and getters that only existed because we didn't have real request caching before. The contact page alone shed a remarkable amount of store code.
- A data-fetching library major version. Mid-migration, the query library we adopted shipped a major version that removed the
onSuccess/onErrorcallbacks we'd built patterns around. We had to introduce a shim and migrate call sites off the old callback style before we could even take the bump. Migrating on top of a moving target, again. - Jest to Vitest. Vite as the build tool meant our Jest test suite was living in a different universe than our app. Porting the tests meant reconciling globals, rewriting mock shapes, and fixing a long tail of tests that had quietly depended on Jest-specific behavior. Tests that "passed" told you nothing until they were actually running against the real runtime.
- Tooltips, i18n, analytics, the whole long tail. The tooltip library changed. Internationalization moved from
this.$root.$i18nand$nuxt.$i18nto agetI18ncomposable. Analytics helpers that hung offthis.$gtmbecameuseGtm(). None of these were hard on their own. All of them together, across a codebase this size, were a slow grind of a thousand small cuts.
Struggle #7: "It compiles" means nothing
The single most important habit we built was this: the only source of truth is npm run build and the app actually running. Vue 3 and its tooling will let a shocking amount of broken code past the type checker and the linter. A component that references a removed API, a template using deprecated slot syntax, a subtly wrong reactive access: none of it necessarily errors until the code path actually runs in the browser.
We eventually made "did you run a real build locally?" a non-negotiable part of every migration PR. It caught import errors, type errors, and syntax the editor happily accepted but Vite rejected. Before that discipline, we shipped a few "done" tickets that were absolutely not done, and we paid for it in QA.
The finish line, and the hotfix tail
We finally merged the Nuxt 3 release and shipped it. And then, because this is how software actually works, the real bug reports started rolling in from production. A campaign modal that rendered a blank selector because of a scope-resolution issue. A validation field that rejected zero as an input. Onboarding modals firing events twice because an emit wasn't declared. A wave of FOXSUP hotfix tickets, each one a small Vue 3 behavioral difference that no amount of internal QA had surfaced, because nothing exercises software like a few thousand real users doing things you never imagined.
That tail is part of the migration. Budget for it. The day you merge is not the day you're done. It's the day the last phase starts.
What I'd actually tell you
If you're staring down a migration like this, here's the short version:
- Respect the scope. This is a rewrite wearing a migration's clothes. Plan and staff it like a rewrite.
- Merge from mainline relentlessly. Divergence is the enemy. The longer your branch lives apart from
stage, the more the conflicts compound. - Fear the silent failures most. The build errors are the easy ones. You'll fix them because you have to. It's the event that fires into a dead bus and the input that quietly drops keystrokes that will eat your weeks. QA behavior, not just compilation.
- Migrate patterns, not just syntax. The event bus, the mixins, the Vuex data-fetching: the migration is a chance to replace shortcuts with structure. Take it. You're already in the file.
- Trust nothing but a real build and a running app. "It compiles" is a lie the toolchain tells you.
- The hotfix tail is real. You are not done at merge. You're done a month after merge, when the production reports stop.
Would I do it again? Yes, because the alternative was slowly rotting on a dead framework. Was it fun? Ask me on a good day. But there's a specific, hard-won satisfaction in watching a fourteen-month migration finally boot up clean, serve real users, and quietly do its job as if the whole ordeal never happened.
That last part is the payoff. The app doesn't care how much it cost to get there. It just works.