Vue 2 hit end-of-life on December 31, 2023. We all knew it was coming, and yet.
Our codebase had been running on Vue 2 and Nuxt 2 for a few years — multiple teams, a shared component library, Options API everywhere with some Class Components mixed in. Not a small job.
I led that migration. Here's what I learned.
Start with the compat build, then ditch it
Vue has @vue/compat — a special Vue 3 build that keeps most Vue 2 behaviour working but yells at you with deprecation warnings. The idea is you run it, fix warnings one by one, and slowly get to proper Vue 3.
It works for understanding the scope of the problem. Use it for that. We ran it for two weeks, went through every warning, made a list. Then we moved to real Vue 3 directly.
The trap is staying on it too long. It gives you a false sense of progress. Your app "works" but you haven't actually migrated anything.
Composition API isn't optional
You can write Vue 3 with the Options API. But if you're going to bother migrating, don't.
Moving from mixins to composables was one of the best parts of this whole thing. We had naming collision bugs that had been living in the codebase for years — just silently wrong, no errors, just wrong. Composables fixed that. Everything is explicit, everything is typed, and you can actually test them in isolation.
This is roughly what the change looked like for a form mixin we had across a dozen components:
// Before: mixin — implicit this, no types, collision waiting to happen
export const formMixin = {
data() {
return { isSubmitting: false, errors: {} }
},
methods: {
async submit() {
this.isSubmitting = true
// ...
}
}
}
// After: composable — you can actually see what's going on
export function useFormState() {
const isSubmitting = ref(false)
const errors = ref<Record<string, string>>({})
async function submit(handler: () => Promise<void>) {
isSubmitting.value = true
try {
await handler()
} finally {
isSubmitting.value = false
}
}
return { isSubmitting, errors, submit }
}Takes more time upfront. Worth it.
The breaking changes that actually hurt
Most of the documented ones are fine. A few cost us real time:
v-model on components. In Vue 2 it used value and input. In Vue 3 it's modelValue and update:modelValue. Every custom form component needed updating. We wrote a codemod to catch most of them, but stuff still slipped through to QA.
Filters are removed. Completely gone. We had dateFilter, currencyFilter, a few others — all registered globally. They had to become utility functions or composables. Better, but tedious. Finding every {{ value | currency }} across all the templates is not fun.
$listeners merged into $attrs. This one was quiet. Some wrapper components that forwarded listeners broke and the errors weren't always pointing you in the right direction.
Nuxt 3 is actually a different framework
The Vue part is manageable. Nuxt 3 is where it gets interesting.
SSR is explicit now. In Nuxt 2, SSR was the default and you opted out of it. In Nuxt 3, useAsyncData and useFetch give you more control, but you actually have to understand what runs where. Server only, client only, both. Every developer on the team needs to get this — there's no shortcut.
Auto-imports. Nuxt 3 auto-imports composables, components, and Vue APIs. You get ref, computed, your own composables — no import statements needed. New developers found this confusing at first ("where does ref come from?"), but once it clicks, it's actually nice.
The asyncData → useAsyncData migration also needs a callout. They look similar. They're not. Especially around how data is serialised between server and client.
What I'd do differently
Migrate the component library first. We did it in parallel with the app, which meant there was a period where some components were Vue 3 and some weren't. Annoying. Do the library as a prerequisite.
Wrap Vuex modules in composables early. Instead of migrating store modules wholesale, build a composable layer on top first. useUserStore() as an abstraction means you can swap the underlying thing later without touching components. We moved some modules to Pinia this way, no component changes needed.
Budget time for the test suite. Vue Test Utils 2 has a different API. Some of our patterns didn't survive. We underestimated this pretty badly.
Was it worth it
Yes. The codebase is genuinely better. Composables are easier to reason about and test. TypeScript integration is tighter. The Nuxt 3 dev server is noticeably faster.
It also took longer than planned. It always does.
If you're still on Vue 2, the ecosystem is only going to keep moving away from it. The longer you wait, the bigger the gap gets.
If you're planning something similar and want to compare notes, reach out.