Vue 2 to Vue 3 Migration at Scale: Lessons from a Production Migration

A guide to migrating large Vue 2 codebases to Vue 3 without downtime. Learn the strategies, tooling, and patterns that worked across dozens of components and the full test suite.

The Challenge: Why We Needed to Migrate

We faced a critical decision when Vue 3 became stable in 2021. Our Vue 2 codebase had grown large across multiple teams.

The pain points were real:

  • Type Safety: Vue 2 + TypeScript was never fully integrated. The IDE caught some issues; the rest surfaced in QA
  • Composition Reuse: Mixins led to naming collisions and unclear data flow. Multiple teams independently "solved" the same problems
  • Performance: Bundle size kept growing. Tree-shaking didn't work well with Vue 2's runtime structure
  • Developer Experience: New team members struggled with implicit this context and composition patterns

The business case was compelling: fewer bugs in production, faster onboarding, a smaller bundle.

Our Migration Strategy

Phase 1: Set Up Build Tooling (1 week)

We chose Vite + @vitejs/plugin-vue instead of webpack. This decision paid dividends:

typescript
// vite.config.ts
export default {
  plugins: [vue(), visualizer()],
  build: {
    rollupOptions: {
      output: { manualChunks: customChunks }
    }
  }
}

Bundle analysis immediately showed why the tooling move pays for itself:

  • Vite's default code splitting cut the main bundle substantially before we touched a component
  • Build times dropped from tens of seconds under webpack to single digits under Vite
  • Hot Module Replacement went from a coffee-sip wait to effectively instant

Phase 2: Incremental Component Migration (6 weeks)

We didn't rewrite everything. Instead, we:

  1. Identified high-ROI components — components used across multiple products, or frequently modified
  2. Created a co-existence layer — Vue 2 and Vue 3 components lived side-by-side
  3. Migrated by priority — a steady per-engineer cadence, not a big-bang rewrite

Key Pattern: The Composition API Advantage

typescript
// Old Vue 2 (mixin-based)
export const userMixin = {
  data() {
    return { user: null, loading: false }
  },
  methods: {
    async fetchUser(id) {
      this.loading = true
      this.user = await api.getUser(id)
      this.loading = false
    }
  },
  computed: {
    isAuthor() {
      return this.user?.id === this.currentUserId
    }
  }
}

// New Vue 3 (Composition API)
export const useUser = (userId: Ref<string>) => {
  const user = ref<User | null>(null)
  const loading = ref(false)
  
  const fetchUser = async (id: string) => {
    loading.value = true
    user.value = await api.getUser(id)
    loading.value = false
  }
  
  const isAuthor = computed(() => user.value?.id === userId.value)
  
  watchEffect(() => {
    if (userId.value) fetchUser(userId.value)
  })
  
  return { user, loading, isAuthor, fetchUser }
}

The Composition API version:

  • Eliminates naming collisions — composables are just functions, no implicit merging
  • Makes data flow explicit — you see exactly which data is used
  • Enables better tree-shaking — unused composables are easier to identify
  • Improves TypeScript — full type inference without as any

Phase 3: Handle the Breaking Changes

Vue 3's breaking changes were documented, but the scale matters. Here's what actually cost us the most time:

1. Event Handling (3 days spent)

javascript
// Vue 2 - event modifiers worked universally
<input @keyup.enter="submit" />
<div @click.self="close" />

// Vue 3 - custom components don't auto-inherit modifiers
<CustomInput @keyup.enter="submit" /> // ❌ Won't work

// Solution: Explicitly bind in component
<input @keyup.enter="$emit('keyup-enter')" />

We had instances of this all over the codebase. Automated search + manual review took longer than the code changes themselves.

2. Async Components

javascript
// Vue 2
const AsyncComponent = () => import('./AsyncComponent.vue')

// Vue 3 - requires `defineAsyncComponent`
import { defineAsyncComponent } from 'vue'
const AsyncComponent = defineAsyncComponent(
  () => import('./AsyncComponent.vue')
)

Dozens more locations to update. Pro tip: codemods exist for this, but they're not perfect. Invest time in understanding edge cases.

3. Filters → Computed Properties

javascript
// Vue 2
{{ message | capitalize }}
// Defined as: Vue.filter('capitalize', ...)

// Vue 3 - must be explicit
{{ capitalizedMessage }}
// const capitalizedMessage = computed(() => capitalize(message.value))

Dozens of filter usages. We created a utility module to centralize custom filters as functions.

Phase 4: Test Migration (Parallel, 4 weeks)

Our Jest test suite had to be updated to work with Vue 3. The good news: tests were actually easier to write.

typescript
// Vue 2 test with vue-test-utils
const wrapper = mount(MyComponent, {
  mocks: { $t: (key) => key },
  propsData: { user: testUser }
})
expect(wrapper.vm.formattedName).toBe('John Doe')

// Vue 3 test - cleaner syntax
const { vm } = mount(MyComponent, {
  props: { user: testUser },
  global: { mocks: { $t: (key) => key } }
})
expect(vm.formattedName).toBe('John Doe')

We migrated the full test suite.

It took weeks, not days — but many tests revealed brittleness in the original code. This was actually valuable.

The Results

The migration shipped incrementally, with zero downtime, and the codebase came out the other side smaller, faster to build, and easier to type-check.

What held up qualitatively:

  • Tree-shaking finally worked, so dead code stopped shipping
  • Full TypeScript inference replaced as any scattered through the codebase
  • Vite's dev server made the edit-refresh loop feel free again

Lessons for Your Codebase

  1. Tooling matters as much as code — Vite's superior DX made the whole migration feel faster
  2. Go incremental — don't try a big bang rewrite. Parallel migration kept the team shipping
  3. Type safety is an enabler — we spent 20% on migration, but gained 80% in error prevention
  4. Test coverage reveals truth — broken tests weren't a migration problem; they revealed logic bugs
  5. Plan for edge cases — our 10% "final polish" took 20% of the time (event handling, filters, etc.)

What We'd Do Differently

  • Start with Vite first — migrate tooling before components (the performance boost motivated the team)
  • Create better codemods — we wrote several after the fact that would have saved days
  • Allocate 15-20% for unknowns — we estimated 6 weeks; it took 8. That's normal for large migrations
  • Train junior engineers — force 2-3 people to migrate components solo. They become experts quickly

The Vue 3 migration wasn't just about staying current. It fundamentally improved how we ship features, onboard engineers, and debug in production. If you're on the fence, the ROI is worth it.