Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 062 / 100

Migrating Vue 2 to Vue 3: a hands-on guide for engineers

Upgrade path and pitfalls.

Expona Admin8 min read

Vue 2 reached end of life

Vue 2 officially entered end-of-life on September 30, 2023, meaning no new security patches or bug fixes are released. If you're running production applications on Vue 2, staying on that version exposes your codebase to security vulnerabilities and keeps your team locked out of ecosystem improvements. We've found that teams delaying migration often face compounding technical debt: dependencies become outdated faster, hiring new engineers becomes harder because the skill is fading from the market, and eventually you're forced into a more painful, rushed migration than you'd have planned.

Vue 3 has been production-ready since September 2020, and the ecosystem is mature. Most of your existing dependencies likely have Vue 3 versions already available. The good news is that Vue provides a structured migration path rather than asking you to rewrite everything at once.

Migration build helps step by step

The Vue 3 migration build is your safety net. It's a special version of Vue 3 that includes compatibility warnings and compat code for Vue 2 APIs. Rather than rewriting your entire codebase in one go, you can upgrade incrementally, fixing warnings as your team has time.

Start by installing the migration build:

typescript
npm install vue@3 @vitejs/plugin-vue

Then update your build configuration. If you're using Vite (which we recommend for new projects or migrations), your vite.config.ts might look like this:

typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      vue: 'vue/dist/vue.esm-bundler.js'
    }
  }
})

Now run your application. The migration build will log warnings for every Vue 2 API you're using. You'll see output like "DEPRECATION: filters are not supported in Vue 3. Use method or computed property instead." This gives you a clear roadmap of what needs fixing.

We recommend tackling warnings in batches based on frequency. If you see the same warning across fifty components, write a codemod to fix it. Libraries like jscodeshift are perfect for this. If it's scattered and infrequent, fix them manually as you touch each file.

One real scenario: a mid-sized e-commerce platform we worked with had roughly three hundred Vue files. They ran the migration build and found two hundred deprecation warnings across four categories. By focusing on the most common patterns first—filters, watchers, and lifecycle hooks—they eliminated 70% of warnings in a single sprint.

Replace filters and event bus patterns

Filters were a convenient Vue 2 feature for formatting values in templates. They looked clean but encouraged logic living in the template layer:

typescript
// Vue 2: filters
<template>
  <p>{{ message | capitalize }}</p>
</template>

<script>
export default {
  filters: {
    capitalize: (value) => {
      if (!value) return ''
      value = value.toString()
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
  }
}
</script>

In Vue 3, filters are removed. The recommended pattern is computed properties or methods. Computed properties are preferred when the value depends on reactive data:

typescript
// Vue 3: computed property
<template>
  <p>{{ capitalizedMessage }}</p>
</template>

<script setup>
import { computed, ref } from 'vue'

const message = ref('hello world')

const capitalizedMessage = computed(() => {
  const value = message.value
  if (!value) return ''
  return value.charAt(0).toUpperCase() + value.slice(1)
})
</script>

For global filters (like currency formatting), create a utility function and apply it where needed:

typescript
// utils/formatters.ts
export const capitalize = (value: string) => {
  if (!value) return ''
  return value.charAt(0).toUpperCase() + value.slice(1)
}

export const currency = (value: number, locale = 'en-US') => {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: 'USD'
  }).format(value)
}

// In your component
<template>
  <p>{{ capitalize(message) }}</p>
  <p>{{ currency(amount) }}</p>
</template>

<script setup>
import { capitalize, currency } from '@/utils/formatters'
import { ref } from 'vue'

const message = ref('hello')
const amount = ref(1234.56)
</script>

Event buses were another Vue 2 pattern for cross-component communication without prop drilling. You'd create a global event emitter and listen for arbitrary events throughout your app:

typescript
// Vue 2: event bus
// eventBus.js
export const eventBus = new Vue()

// Component A
this.$eventBus.$emit('user-logged-in', { id: 123 })

// Component B
this.$eventBus.$on('user-logged-in', (user) => {
  console.log('User logged in:', user)
})

Event buses work but are hard to trace and debug. In Vue 3, we strongly prefer one of two patterns: either Pinia state management for app-wide state, or prop/emit for parent-child communication. Pinia makes data flow explicit and debuggable.

If you had an event bus for authentication state, migrate it like this:

typescript
// stores/auth.ts (Pinia)
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const isLoggedIn = ref(false)

  const login = (userData) => {
    user.value = userData
    isLoggedIn.value = true
  }

  const logout = () => {
    user.value = null
    isLoggedIn.value = false
  }

  return { user, isLoggedIn, login, logout }
})

// In any component
<script setup>
import { useAuthStore } from '@/stores/auth'

const auth = useAuthStore()

const handleLogin = () => {
  auth.login({ id: 123, name: 'John' })
}
</script>

This approach is superior because Pinia maintains a single source of truth, your IDE can autocomplete store methods, and Vue DevTools shows you every state change with full context.

Move to Pinia and Vite

Vuex was the official state management library for Vue 2, but Vue 3's ecosystem has moved to Pinia. Pinia is simpler, has better TypeScript support, and doesn't require as much boilerplate. If you used Vuex, the migration is straightforward:

typescript
// Before: Vuex store (Vue 2)
const store = new Vuex.Store({
  state: {
    products: [],
    cart: []
  },
  mutations: {
    setProducts(state, products) {
      state.products = products
    },
    addToCart(state, product) {
      state.cart.push(product)
    }
  },
  actions: {
    async fetchProducts({ commit }) {
      const response = await fetch('/api/products')
      const data = await response.json()
      commit('setProducts', data)
    }
  }
})

// After: Pinia store (Vue 3)
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useProductStore = defineStore('products', () => {
  const products = ref([])
  const cart = ref([])

  const fetchProducts = async () => {
    const response = await fetch('/api/products')
    products.value = await response.json()
  }

  const addToCart = (product) => {
    cart.value.push(product)
  }

  return { products, cart, fetchProducts, addToCart }
})

Pinia's composition API style is more intuitive and requires less mental overhead. There's no distinction between mutations and actions—you just write functions that update state.

For your build tool, move from Vue CLI or custom Webpack to Vite. Vite is dramatically faster and the Vue 3 ecosystem has adopted it widely. Install it:

typescript
npm create vite@latest my-app -- --template vue

A typical Vite + Vue 3 config:

typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  },
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true
      }
    }
  }
})

Vite's hot module replacement (HMR) is instantaneous—you'll notice the difference in developer experience immediately. Build times are also an order of magnitude faster than Webpack.

Common mistakes

Rushing the migration: Teams sometimes disable the migration build's compat layer too early or try to rewrite everything at once. This leads to missed edge cases and regressions. Stay on the migration build for at least one sprint; use warnings to guide your refactoring incrementally.

Not testing thoroughly: Vue 3's reactivity system is different from Vue 2's. Some edge cases around array mutations or deeply nested objects behave differently. Write tests as you migrate each feature, and run your full test suite against the new version.

Forgetting lifecycle hook changes: Vue 3 renamed many lifecycle hooks. beforeCreate and created don't exist in the Composition API—setup runs instead. beforeMount is now onBeforeMount. These changes are common deprecation warnings.

Keeping old dependencies: After upgrading Vue, audit your npm packages. Some libraries won't work with Vue 3. Check for vue-router v4, nuxt v3, and Vuetify v3 or Headless UI. Leaving Vue 2 dependencies in place can cause subtle bugs and conflicts.

Key takeaways

  • Vue 2's end-of-life means security and hiring risks; migration is urgent, not optional.
  • Use the migration build to tackle deprecations incrementally rather than rewriting everything at once.
  • Replace Vue 2 filters with computed properties or utility functions; migrate event buses to Pinia state management.
  • Move from Vuex to Pinia for simpler, more testable state—less boilerplate, better TypeScript, same power.
  • Adopt Vite as your build tool; the speed improvement is substantial and the Vue 3 ecosystem expects it.
  • Test thoroughly as you go; Vue 3's reactivity differs subtly from Vue 2 in edge cases.
  • Audit all dependencies after upgrading; old Vue 2 libraries can introduce conflicts and unexpected behavior.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles