The Options API still runs a lot of production Vue. Long-lived dashboards, internal tools, the cohort of apps that started in 2018 and never had an excuse to rewrite — all valid, all working, all eventually facing the same question: do we move to the Composition API, and if yes, how do we do it without setting the codebase on fire?

The honest answer is that you can keep an Options API codebase happy for years. Vue 3 supports both styles indefinitely, the docs cover both, the tooling works for both. But once a team has tasted <script setup>, the Options API starts to feel verbose, and once a feature needs reusable cross-component logic, the absence of composables shows. So the question stops being if and becomes how do we migrate without breaking things or stalling product work.

This article walks through the migration playbook that has worked for me on Vue 2.7 and Vue 3 codebases — incremental, behavior-preserving, reviewable in small chunks, and never blocking shipping.

Don't Start By Rewriting

The most expensive mistake I see is the team that decides to "modernize the codebase" by rewriting fifty components in a single sprint. The PR is unreviewable. The QA pass is unreviewable. The bugs that ship are subtle — a watcher that used to be immediate: true now isn't, a created hook that ran on the server now runs on the client, a mixin's lifecycle ordering quietly inverts. Six weeks later the team is firefighting the migration they were supposed to be done with.

The right move is to migrate one component at a time, one concern at a time, with the existing tests still green at every commit. If the codebase doesn't have tests yet, that's the first migration: characterization tests on the components you intend to refactor. The pattern from Michael Feathers's Working Effectively with Legacy Code applies cleanly here — write tests that pin the current behavior, then refactor with confidence that you haven't changed it.

TypeScript
// PriceCard.spec.ts — characterization, written BEFORE the refactor
import { mount } from '@vue/test-utils'
import PriceCard from './PriceCard.vue'

it('renders the discounted total when a coupon is applied', async () => {
  const wrapper = mount(PriceCard, {
    props: { items: [{ price: 100 }, { price: 50 }], coupon: 'SAVE10' },
  })
  expect(wrapper.text()).toContain('Total: $135.00')
})

That test is the safety net. After the migration, the same test still passes — you've changed the implementation, not the contract.

Vue 2.7 Was The Stepping Stone

A historical note worth knowing if you're still on Vue 2: Vue 2.7 backported the Composition API and <script setup> to the Vue 2 line. That meant teams could start writing new components in the new style without the full Vue 3 migration. Many codebases used 2.7 as a halfway house — migrate the API first, migrate the major version later — and it works.

If you're still on Vue 2 and not yet on 2.7, upgrade to 2.7 first. It removes one entire axis of risk from the bigger migration. If you're already on Vue 3 with Options API components, skip ahead.

Two <script> Tags Are Allowed

The technique that unlocks gradual migration inside a single component: Vue's SFC syntax allows both a <script> block and a <script setup> block in the same file. The Options API code in the regular <script> keeps working. New logic — composables, reactive state, computed values you want to extract — goes into <script setup>. They share the same template.

Vue
<script>
export default {
  name: 'PriceCard',
  props: ['items', 'coupon'],
  data() {
    return { hovered: false }
  },
  methods: {
    onHover(v) { this.hovered = v },
  },
}
</script>

<script setup>
import { computed } from 'vue'
import { useCouponDiscount } from '@/composables/useCouponDiscount'

const props = defineProps(['items', 'coupon'])
const { discountedTotal } = useCouponDiscount(() => props.items, () => props.coupon)
const formatted = computed(() => `Total: $${discountedTotal.value.toFixed(2)}`)
</script>

<template>
  <div :class="{ hovered }" @mouseenter="onHover(true)" @mouseleave="onHover(false)">
    <p>{{ formatted }}</p>
  </div>
</template>

This is the workhorse pattern. You move logic out of the Options API one block at a time — first a computed, then a watcher, then mounted, then the data — until the Options block is empty and you can delete it. Every commit ships, every commit is reviewable, every commit is reverted if it breaks.

A caveat: props declared in both blocks is an error. Pick one location for props (usually <script setup>) and emit declarations, and let the other block stay focused on the bits you haven't migrated yet.

Migrate By Concern, Not By Line Count

The order I follow inside a single component:

  1. Props and emits. Move the props: { ... } and emits: [...] declarations into defineProps / defineEmits. This unlocks template-level type inference and is mechanical.
  2. Computed properties. computed: { x() { return ... } } becomes const x = computed(() => ...). Read-only computeds map cleanly. Writable computeds (get/set) keep the same shape.
  3. Watchers. watch: { id: { handler, immediate, deep } } becomes watch(() => props.id, handler, { immediate, deep }). Pay attention to the source — Options API watchers on this.id map to a getter () => props.id, not to the prop itself.
  4. Lifecycle hooks. mountedonMounted, beforeUnmountonBeforeUnmount, etc. Note: created and beforeCreate have no Composition equivalents — their code goes directly in setup body.
  5. Methods. Plain functions in the setup block. They're not bound to anything magical anymore — refer to refs by .value, not this.x.
  6. Data. data() { return { x: 0 } } becomes const x = ref(0) (or reactive({}) for related grouped state). This is usually the last step because everything else references it.

Doing it in this order means the Options API data keeps backing the template until the very last commit, and the template doesn't need to change until then.

Composables Are Where The Value Shows Up

The honest reason to migrate is composables. Mixins worked, but they were a name-collision footgun and a debugging nightmare ("where is this.user defined?"). Composables are explicit imports with explicit return values. Once you have one component refactored, the natural next move is to extract the reusable parts into composables that other components can adopt without touching their Options API code.

TypeScript
// src/composables/useCouponDiscount.ts
import { computed, type Ref } from 'vue'

export function useCouponDiscount(
  items: Ref<{ price: number }[]>,
  coupon: Ref<string | null>,
) {
  const subtotal = computed(() =>
    items.value.reduce((acc, i) => acc + i.price, 0)
  )
  const discount = computed(() => coupon.value === 'SAVE10' ? 0.1 : 0)
  const discountedTotal = computed(() =>
    subtotal.value * (1 - discount.value)
  )
  return { subtotal, discount, discountedTotal }
}

That composable is now usable from any component, regardless of which API it's written in — Options API components can call it from setup() and bind the returned refs to data. This is the migration's compounding value.

A diagram showing a Vue SFC split into two script blocks side by side — the left labeled script Options API with data, computed, methods, lifecycle, the right labeled script setup with refs, computed, lifecycle composition functions. Below, an arrow points from a deleted Options block to a finished script setup file.
Two scripts during migration. One script when it&#39;s done.

Tools That Help, And Their Limits

A few tools to know about:

  • VS Code's Vue extension (Vue - Official, formerly Volar) has refactor commands and a "Convert to script setup" code action that handles the mechanical parts of common cases. It is not a full migrator — it won't refactor mixins, scoped slots in render functions, or non-trivial Options computeds. Use it for the boilerplate, not the thinking.
  • eslint-plugin-vue has rules to warn on Options API usage in mixed codebases, which is useful once you're past the halfway point and want to prevent new Options API components from landing.
  • vue-eslint-parser + custom codemods — if you have hundreds of components, a one-off codemod to migrate the boilerplate (props, simple computeds, lifecycle renames) can save days. AST-based, write it once, dry-run on a branch.

I would not trust an automated tool to migrate a complex component without review. The tools handle 70% of the lines and 0% of the careful judgment about what should become a composable.

The Mixins Trap

If your Options API codebase relies on global or local mixins, the migration is harder than the syntax change suggests. Mixins implicitly inject names into this, and the Composition API has no equivalent — composables return values explicitly.

The safe path: convert each mixin into a composable first, while keeping the mixin in place. Update components to call the composable instead of relying on the mixin's injection. Once no component uses the mixin, delete it. Same as the two-script pattern, but at the codebase level.

TypeScript
// Before: mixins/withUser.js
export default {
  computed: {
    currentUser() { return this.$store.state.user }
  }
}

// After: composables/useCurrentUser.ts
import { computed } from 'vue'
import { useStore } from 'vuex'  // or Pinia equivalent

export function useCurrentUser() {
  const store = useStore()
  return { currentUser: computed(() => store.state.user) }
}

Components migrate one at a time from mixins: [withUser] and this.currentUser to const { currentUser } = useCurrentUser() and currentUser.value.

A One-Sentence Mental Model

A safe Options-to-Composition migration is a sequence of small, reviewable, behavior-preserving moves — characterize with tests, run two scripts in the same SFC, migrate concern by concern starting with props, and extract composables as the new shared layer — never a big-bang rewrite. The reward is not the new syntax; it's the composables you build along the way, which keep working long after the last data() block is gone.