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
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.
1. Upgrade Vue And Vue-Compatible Packages
Start by upgrading the Vue runtime and the ecosystem packages that must match Vue 3.
Common upgrades include:
vuefrom Vue 2 to Vue 3vue-routerfrom v3 to v4vuexfrom v3 to v4, or migrate to Pinia if the project allows it@vue/test-utilsfrom v1 to v2vue-loaderto a Vue 3-compatible versionUI 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.
2. Replace The Vue 2 App Bootstrap
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.
3. Replace Vue.use With app.use
In 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}`);
};
},
};
4. Move Prototype Globals To globalProperties
Vue 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);
},
},
};
5. Update Router Setup
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.
6. Update Store Setup
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.
7. Migrate v-model Contracts
One 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"
/>
8. Replace .sync With Named v-model
Vue 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);
},
},
};
9. Add Explicit emits
Vue 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.
10. Update Slot Syntax
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.
11. Replace Removed Reactivity Helpers
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
);
12. Rename Lifecycle Hooks
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.
13. Replace Vue Instance Event Buses
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.
14. Update Directives
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.
15. Update Async Components
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.
16. Update Global Components
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);
17. Update UI Library Usage
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-modelbindingsUpdating 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" />
18. Update Scoped CSS Deep Selectors
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.
19. Keep Options API Where It Reduces Risk
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.
20. Update Tests And Tooling
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.
21. Validate Behavior In Batches
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.
Final Checklist
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-modelcontracts are updated..synchas 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.



