Vue 3 in depth

API styles and migrations

Options API versus Composition API and why the difference is architectural, the Vue 2 to 3 breaking changes worth remembering, Nuxt 3 to 4 to 5, and how a senior runs a migration.

Most companies hiring a senior Vue engineer in 2026 have a codebase with history: Options API components from 2019, a Vuex store, maybe a Nuxt 2 app still running somewhere. "Have you migrated a large codebase" is a real question, and so is "Options or Composition, and why". The good answer is architectural, not stylistic.

Know

  • Same reactivity, different organisation. Options API groups code by kind (data, computed, methods, watch); Composition API groups it by feature and lets you extract a feature into a composable. Both run on the same reactivity system; this.count and count.value are the same Proxy underneath.
  • Why Composition wins at scale: logic reuse without mixins (mixins have implicit sources, name collisions and no types), full TypeScript inference without decorators or Vue.extend, better tree-shaking (unused APIs are not bundled), and <script setup> compiles to less code than a component options object.
  • Options API is not deprecated in Vue 3, but Nuxt 5 turns Options API support off by default (__VUE_OPTIONS_API__: false), which removes its runtime from the bundle Nuxt 5 . Existing Options components in a Nuxt 5 app need the flag re-enabled or a migration.
  • <script setup> macros: defineProps, defineEmits, defineModel, defineExpose, defineSlots, defineOptions, withDefaults. Top-level await makes the component async, which requires a <Suspense> ancestor; Nuxt pages have one.
  • Vue 2 → 3 breaking changes worth remembering: createApp instead of new Vue; fragments (multiple roots) and therefore inheritAttrs matters more; v-model on components uses modelValue/update:modelValue and .sync is gone; the emits option; filters removed; $children, $on/$off (the event bus) and Vue.set removed because Proxies track everything; destroyed → unmounted; transition classes v-enter-from; v-if now has priority over v-for; key goes on <template v-for>; async components via defineAsyncComponent; Vuex → Pinia; Vue Router 4's createRouter and guard return values; IE11 dropped.
  • Nuxt 3 → 4: app/ becomes the source directory, shared/ appears, useAsyncData/useFetch default data to undefined and are shallow-reactive, request deduplication changed (dedupe), route metadata and component names are normalised, compatibilityVersion: 4 on Nuxt 3.12+ let you opt in early. Nuxt 3 reached end of life on 31 July 2026. A codemod recipe (npx codemod@latest nuxt/4/migration-recipe) handles the mechanical parts.
  • Nuxt 4 → 5 (estimated Q4 2026, gated on Nitro 3): server imports from nuxt/server, web-standard Request/Response in Nitro 3 and h3 v2, the Vite Environment API, Options API off, typed pages on by default, case-sensitive routing, callHook no longer awaited. The landscape section has the full list and the readiness plan.
  • How a senior runs a migration: measure first (how many components, which patterns), build the safety net (E2E on critical flows, typecheck, hydration-warning test), migrate incrementally behind the same tests (strangler pattern, one route or one feature at a time), use compat builds and codemods for the mechanical work, and keep the business case explicit: end-of-life dates, hiring, performance, security releases.

How it works

The same component in both styles, which is the fastest way to explain the difference:

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  props: { userId: { type: String, required: true } },
  emits: ['saved'],
  data: () => ({ name: '', saving: false }),
  computed: {
    canSave(): boolean { return this.name.trim().length > 0 && !this.saving },
  },
  watch: {
    userId: { handler: 'load', immediate: true },
  },
  methods: {
    async load() { this.name = (await $fetch<{ name: string }>(`/api/users/${this.userId}`)).name },
    async save() {
      this.saving = true
      await $fetch(`/api/users/${this.userId}`, { method: 'PATCH', body: { name: this.name } })
      this.saving = false
      this.$emit('saved')
    },
  },
})
</script>

A mixin becoming a composable, the migration people actually do most often:

app/composables/usePagination.ts
// Before: a mixin adding `page`, `perPage`, `pageCount` and `next()` to any component,
// with no types and a silent collision if the component already had `page`.
export function usePagination(total: MaybeRefOrGetter<number>, perPage = 20) {
  const page = ref(1)
  const pageCount = computed(() => Math.max(1, Math.ceil(toValue(total) / perPage)))
  const next = () => { page.value = Math.min(page.value + 1, pageCount.value) }
  const prev = () => { page.value = Math.max(page.value - 1, 1) }
  return { page, pageCount, next, prev }
}
Gotcha· Selling a migration as 'the new syntax is nicer'

Nobody funds a rewrite for aesthetics. The arguments that land: Nuxt 3 is end-of-life and no longer receives security releases; Options API support is compiled out in Nuxt 5; mixin-based code cannot be typed; the composable extraction makes the logic testable. Bring numbers: components counted, test coverage on the critical flows, an estimate per feature, and a first slice that ships in a week.

Verify before the interview:

The Nuxt 5 release status and the exact list of breaking changes; both move between nightlies. Also confirm whether Options API is disabled by default in the released version.

docs ↗

Exercise

Exercise
  • Take an Options API component you have written (or the one above) and convert it to <script setup>; then extract its feature into a composable and write one Vitest test for it.
  • Run npx codemod@latest nuxt/4/migration-recipe on a copy of a Nuxt 3 project, read the diff, and note which changes it could not make.
  • Write a one-page migration proposal for a fictional 200-component Vue 2 app: measurements, safety net, first slice, timeline, risks. Practise presenting it in three minutes.

Be able to say

Be able to say· Options API or Composition API, and why?

"Composition API, for architectural reasons rather than taste. It groups code by feature so a component reads as a list of concerns, and each concern can be lifted into a composable without the implicit sources and name collisions of mixins. It types fully with plain TypeScript inference, it tree-shakes, and <script setup> compiles to less code. Options API is fine for small components and is not deprecated in Vue 3, but Nuxt 5 compiles its runtime out by default, so I treat remaining Options components as migration backlog."

Be able to say· How would you migrate a large Vue 2 or Nuxt 3 codebase?

"Measure, protect, then move incrementally. Measure: count components, find the patterns that will not port mechanically, mixins, filters, $on buses, Vue.set. Protect: E2E tests on the critical flows, typecheck in CI, a hydration-warning test if it is SSR. Then migrate one route or feature at a time behind those tests, using the compat build and codemods for the mechanical parts and doing the mixin-to-composable work by hand. I keep the business case explicit, end-of-life dates, security releases, hiring, and I ship a first slice within the first week so the migration is real rather than a plan."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.