Skip to main content

Command Palette

Search for a command to run...

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

Updated
10 min read
Migrating From Vue 2 to Vue 3: A Practical Checklist With Examples
A

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:

  • 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.

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-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" />

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:

  1. Upgrade dependencies and build tooling.

  2. Fix application bootstrap.

  3. Migrate router and store.

  4. Migrate plugins and global APIs.

  5. Migrate shared components.

  6. Migrate feature components.

  7. Update styles and UI library overrides.

  8. Update tests.

  9. 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-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.

4 views