Migrating From Vue 2 to Vue 3: A Practical Checklist With Examples

I am a full stack developer. Currently doing some project on nextjs / nodejs
Working as SDE 3 in Netomi Here’s my Resume: https://resume.devaman.dev
Search for a command to run...

I am a full stack developer. Currently doing some project on nextjs / nodejs
Working as SDE 3 in Netomi Here’s my Resume: https://resume.devaman.dev
No comments yet. Be the first to comment.
19/May/2023 While using mini-css-extract-plugin with vuejs and webpack 5, I was getting problem related to CSS priority. Scoped Css/Scss was not getting higher priority than common css. To resolve this issue we had to use oneof rule. In resourceQuery...

Hello everyone! I recently had the opportunity to work with AWS IoT to fetch real-time data. During the migration from Webpack 4 to Webpack 5, we encountered some issues with the aws-iot-device-sdk. While building our project, we encountered errors r...

Hey everyone , Webpack has released some new cool feature called module federation. Module Federation allows a JavaScript application to dynamically load code from another application and in the process, share dependencies. If an application consumi...

In this post I will tell you about on how to submit a pull request to the organization on which you are thinking to contribute. Now lets gets started with the steps... 1) Step 1 : Fork the repo. 2) Step 2 : Clone the repository.Open the terminal and ...

Amit Chambial Blog.
14 posts
Full Stack Developer and a maker. See my portfolio at https://portfolio.devaman.dev
Recently i worked on Vue 3 migration. Migrating a Vue 2 application to Vue 3 is not just a dependency upgrade. The real work is in updating the application bootstrap, router, store, plugins, component contracts, events, slots, directives, styling, tests, and third-party integrations.
When I approach this kind of migration, I try to keep the goal simple: preserve existing behavior while gradually moving the application onto Vue 3-compatible APIs. A successful migration should feel boring to users and maintainable for developers.
This guide captures the practical steps that can be reused across most Vue 2 projects.
Start by upgrading the Vue runtime and the ecosystem packages that must match Vue 3.
Common upgrades include:
vue from Vue 2 to Vue 3
vue-router from v3 to v4
vuex from v3 to v4, or migrate to Pinia if the project allows it
@vue/test-utils from v1 to v2
vue-loader to a Vue 3-compatible version
UI libraries, editor wrappers, chart wrappers, drag-and-drop libraries, and utility plugins to Vue 3-compatible versions
This is also a good time to remove Vue 2-only packages that are no longer maintained or no longer needed.
Vue 2 applications usually start with new Vue(...). In Vue 3, the application is created with createApp(...).
Vue 2:
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';
new Vue({
router,
store,
render: h => h(App),
}).$mount('#app');
Vue 3:
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';
const app = createApp(App);
app.use(router);
app.use(store);
app.mount('#app');
This change becomes the foundation for updating plugins, global properties, components, and directives.
Vue.use With app.useIn Vue 2, plugins are installed globally on the Vue constructor. In Vue 3, plugins are installed on the app instance.
Vue 2:
import Vue from 'vue';
import Notifications from 'some-notification-plugin';
Vue.use(Notifications);
Vue 3:
import { createApp } from 'vue';
import Notifications from 'some-notification-plugin';
import App from './App.vue';
const app = createApp(App);
app.use(Notifications);
app.mount('#app');
For custom plugins, update the plugin signature so it receives the app instance.
export default {
install(app, options = {}) {
app.config.globalProperties.$notify = message => {
console.log(`[\({options.prefix || 'app'}] \){message}`);
};
},
};
globalPropertiesVue 2 often uses Vue.prototype to expose shared helpers.
Vue 2:
Vue.prototype.$formatCurrency = value => {
return `$${Number(value).toFixed(2)}`;
};
Vue 3:
const app = createApp(App);
app.config.globalProperties.$formatCurrency = value => {
return `$${Number(value).toFixed(2)}`;
};
Inside Options API components, the helper can still be accessed through this.
export default {
computed: {
displayPrice() {
return this.$formatCurrency(this.price);
},
},
};
Vue Router v4 changes how the router is created. Instead of new Router(...), use createRouter(...).
Vue 2 / Router v3:
import Vue from 'vue';
import Router from 'vue-router';
Vue.use(Router);
export default new Router({
mode: 'history',
routes: [
{ path: '/', component: HomePage },
{ path: '*', redirect: '/' },
],
});
Vue 3 / Router v4:
import { createRouter, createWebHistory } from 'vue-router';
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: HomePage },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
});
Also review navigation guards, redirects, route params, and catch-all routes because their behavior may need small adjustments.
If the application remains on Vuex, use the Vue 3-compatible store creation API.
Vue 2 / Vuex 3:
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
count: 0,
},
mutations: {
increment(state) {
state.count += 1;
},
},
});
Vue 3 / Vuex 4:
import { createStore } from 'vuex';
export default createStore({
state() {
return {
count: 0,
};
},
mutations: {
increment(state) {
state.count += 1;
},
},
});
For new projects or larger refactors, Pinia is also worth considering, but Vuex 4 is often the lower-risk migration path.
v-model ContractsOne of the most common Vue 3 migration tasks is updating custom component v-model.
In Vue 2, custom v-model usually maps to a value prop and an input event.
Vue 2 child component:
<template>
<input :value="value" @input="\(emit('input', \)event.target.value)" />
</template>
<script>
export default {
props: {
value: String,
},
};
</script>
Vue 3 child component:
<template>
<input
:value="modelValue"
@input="\(emit('update:modelValue', \)event.target.value)"
/>
</template>
<script>
export default {
props: {
modelValue: String,
},
emits: ['update:modelValue'],
};
</script>
Parent usage stays clean:
<UserNameInput v-model="name" />
For multiple two-way bindings, use named v-model.
<DateRangePicker
v-model:start-date="startDate"
v-model:end-date="endDate"
/>
.sync With Named v-modelVue 2 .sync is removed in Vue 3.
Vue 2:
<SettingsModal :visible.sync="isSettingsOpen" />
Vue 3:
<SettingsModal v-model:visible="isSettingsOpen" />
The child component should emit the matching update event.
export default {
props: {
visible: Boolean,
},
emits: ['update:visible'],
methods: {
close() {
this.$emit('update:visible', false);
},
},
};
emitsVue 3 encourages component event contracts to be explicit.
export default {
emits: ['save', 'cancel'],
methods: {
saveForm() {
this.$emit('save', {
name: this.name,
email: this.email,
});
},
},
};
This makes components easier to understand and helps prevent accidental event forwarding.
Legacy slot syntax should be replaced with v-slot or shorthand #.
Vue 2:
<UserCard>
<template slot="actions" slot-scope="{ user }">
<button @click="editUser(user)">Edit</button>
</template>
</UserCard>
Vue 3:
<UserCard>
<template #actions="{ user }">
<button @click="editUser(user)">Edit</button>
</template>
</UserCard>
This is usually a mechanical change, but it is important to test screens that rely heavily on scoped slots.
Vue 2 often required \(set and \)delete for reactive object changes. Vue 3 uses proxy-based reactivity, so normal assignment works.
Vue 2:
this.$set(this.userPreferences, 'theme', 'dark');
this.$delete(this.userPreferences, 'legacyMode');
Vue 3:
this.userPreferences.theme = 'dark';
delete this.userPreferences.legacyMode;
When updating arrays or nested objects, prefer clear immutable replacement if it makes the change easier to reason about.
this.items = this.items.map(item =>
item.id === selectedId
? { ...item, selected: true }
: item
);
Some lifecycle hook names changed in Vue 3.
Vue 2:
export default {
beforeDestroy() {
window.removeEventListener('resize', this.onResize);
},
destroyed() {
console.log('component destroyed');
},
};
Vue 3:
export default {
beforeUnmount() {
window.removeEventListener('resize', this.onResize);
},
unmounted() {
console.log('component unmounted');
},
};
The behavior is similar, but the names need to be updated.
Many Vue 2 applications use a Vue instance as an event bus.
Vue 2:
import Vue from 'vue';
export const eventBus = new Vue();
eventBus.$emit('toast', 'Saved successfully');
eventBus.$on('toast', message => {
console.log(message);
});
In Vue 3, use a small emitter library or a simple custom emitter.
import mitt from 'mitt';
export const eventBus = mitt();
eventBus.emit('toast', 'Saved successfully');
eventBus.on('toast', message => {
console.log(message);
});
For larger applications, consider whether events should instead move into a store, composable, or dedicated service.
Directive lifecycle hooks changed in Vue 3.
Vue 2:
Vue.directive('focus', {
inserted(el) {
el.focus();
},
unbind(el) {
el.blur();
},
});
Vue 3:
app.directive('focus', {
mounted(el) {
el.focus();
},
beforeUnmount(el) {
el.blur();
},
});
Review custom directives carefully because they often interact directly with the DOM.
Vue 3 provides defineAsyncComponent for async components.
import { defineAsyncComponent } from 'vue';
export default {
components: {
UserReport: defineAsyncComponent(() => import('./UserReport.vue')),
},
};
For route-level lazy loading, dynamic imports still work well.
const routes = [
{
path: '/reports',
component: () => import('./pages/ReportsPage.vue'),
},
];
If the application uses chunk-based deployment, add runtime handling for failed chunk loads so users are not stuck on a broken screen after a release.
Instead of registering global components on the Vue constructor, register them on the app instance.
Vue 2:
Vue.component('BaseButton', BaseButton);
Vue 3:
app.component('BaseButton', BaseButton);
If a component is loaded dynamically and stored in reactive state, use markRaw when needed.
import { markRaw } from 'vue';
this.activePanel = markRaw(AdvancedSettingsPanel);
If the project uses a Vue 2 UI library, move to its Vue 3-compatible replacement.
The migration usually includes:
Updating imports
Replacing deprecated props and events
Updating v-model bindings
Updating icon usage
Updating programmatic services such as modals, notifications, and messages
Updating CSS overrides because internal class names and DOM structure may have changed
Example of a typical binding change:
<!-- Before -->
<UiDialog :visible.sync="open" />
<!-- After -->
<UiDialog v-model:visible="open" />
Vue 2 projects often use old deep selector syntax.
Vue 2:
<style scoped>
.card ::v-deep .title {
font-weight: 600;
}
</style>
Vue 3:
<style scoped>
.card :deep(.title) {
font-weight: 600;
}
</style>
This is especially important when styling child components from a scoped style block.
Vue 3 supports the Options API, so a migration does not have to become a full rewrite.
This is still valid in Vue 3:
export default {
props: {
initialCount: {
type: Number,
default: 0,
},
},
data() {
return {
count: this.initialCount,
};
},
methods: {
increment() {
this.count += 1;
},
},
};
Use the Composition API when it improves a specific area, such as shared logic, async behavior, or complex state management. Avoid rewriting working components only for style consistency during the first migration pass.
Tests often need small changes after moving to Vue 3.
Example with Vue Test Utils:
import { mount } from '@vue/test-utils';
import CounterButton from './CounterButton.vue';
test('emits increment event', async () => {
const wrapper = mount(CounterButton);
await wrapper.find('button').trigger('click');
expect(wrapper.emitted('increment')).toHaveLength(1);
});
Also update lint rules, build tooling, loaders, compiler options, and test setup to Vue 3-compatible versions.
The safest migration path is usually incremental:
Upgrade dependencies and build tooling.
Fix application bootstrap.
Migrate router and store.
Migrate plugins and global APIs.
Migrate shared components.
Migrate feature components.
Update styles and UI library overrides.
Update tests.
Run full regression testing.
This keeps the migration reviewable and reduces the chance of mixing unrelated changes with Vue 3 compatibility fixes.
Before considering a Vue 3 migration complete, I check that:
The app mounts through createApp.
Router and store are using Vue 3-compatible APIs.
Plugins install through the app instance.
Global helpers use globalProperties.
Custom v-model contracts are updated.
.sync has been replaced.
Slots use Vue 3 syntax.
Lifecycle hooks use Vue 3 names.
\(set, \)delete, and Vue instance event buses are removed.
Directives use Vue 3 lifecycle hooks.
UI library components and styles are updated.
Third-party Vue wrappers are compatible with Vue 3.
Tests and lint rules are updated.
Main user workflows have been manually verified.
The most important lesson is to treat the migration as a compatibility project first, not a redesign or rewrite. Preserve behavior, move APIs forward, and keep each change easy to reason about.