There are four axes of friction: state management (Vuex to Pinia requires rethinking data flow, not just syntax), data fetching (asyncData/fetch to composables), the ecosystem (third-party libraries without Vue 3 support), and the Composition API shift (team conventions, losing this, new reactivity gotchas). The migration should be incremental, ideally using Nuxt Bridge as a stepping stone.
1. Vuex to Pinia
This is not a find-and-replace. The entire mental model changes:
// Nuxt 2: Vuex with mutations, namespaced modules
// store/user.js
export const state = () => ({ user: null })
export const mutations = {
SET_USER(state, user) { state.user = user }
}
export const actions = {
async fetchUser({ commit }, id) {
const user = await this.$axios.$get(`/users/${id}`)
commit('SET_USER', user)
}
}// Nuxt 3: Pinia store
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
async function fetchUser(id: number) {
user.value = await $fetch(`/api/users/${id}`)
}
return { user, fetchUser }
})What changes:
- Mutations are gone. Actions modify state directly.
- Namespaced modules become independent stores that import each other.
this.$store.dispatch('user/fetchUser', id)becomesuseUserStore().fetchUser(id).- String-based action names become typed function calls.
- The store is no longer a global singleton with a rigid structure, it's a composable.
The friction is organizational: every component that uses this.$store or mapState/mapGetters needs rewriting.
2. Data fetching
Every data-fetching pattern changes:
// Nuxt 2: asyncData receives a context object
export default {
async asyncData({ $axios, params, error }) {
try {
const user = await $axios.$get(`/users/${params.id}`)
return { user }
} catch (e) {
error({ statusCode: 404 })
}
}
}<!-- Nuxt 3: composables in script setup -->
<script setup>
const route = useRoute()
const { data: user, error } = await useFetch(`/api/users/${route.params.id}`)
</script>What changes:
asyncDataandfetch(the Nuxt 2 component option) don't exist.- The
contextobject ({ $axios, store, redirect, error }) is gone. Each capability is now a separate composable (useRoute,useRouter,navigateTo,useFetch). $axiosis typically replaced by$fetch(built into Nuxt 3 via ofetch).- Error handling uses
createErroror theerrorref fromuseFetch.
3. Middleware
// Nuxt 2: context-based
export default function ({ store, redirect }) {
if (!store.state.auth.loggedIn) {
redirect('/login')
}
}// Nuxt 3: composable-based
export default defineNuxtRouteMiddleware(() => {
const { loggedIn } = useAuth()
if (!loggedIn.value) {
return navigateTo('/login')
}
})The context parameter disappears entirely. You use composables instead. redirect() becomes navigateTo(). Server middleware is now a separate concept in server/middleware/.
4. Plugins
// Nuxt 2: inject into context
export default function ({ app }, inject) {
inject('analytics', new Analytics())
}
// Usage: this.$analytics.track(...)// Nuxt 3: provide through nuxtApp
export default defineNuxtPlugin((nuxtApp) => {
const analytics = new Analytics()
nuxtApp.provide('analytics', analytics)
})
// Usage: const { $analytics } = useNuxtApp()Every plugin that used inject needs rewriting. Components that accessed injected values through this.$something now use useNuxtApp().
5. Third-party ecosystem
Many Vue 2 / Nuxt 2 libraries didn't survive the transition:
| Library | Status |
|---|---|
vue-class-component | Dead, no Vue 3 equivalent |
vue-property-decorator | Dead |
vuetify@2 | Vuetify 3 exists but the migration took years |
@nuxtjs/axios | Replaced by built-in $fetch |
@nuxtjs/auth | No official Nuxt 3 version, use sidebase/nuxt-auth or build custom |
nuxt-community modules | Some migrated, many abandoned |
You need to audit every dependency early. Some have Nuxt 3 equivalents, some need replacement, some need custom reimplementation.
6. Composition API reactivity gotchas
Developers coming from Options API hit new issues:
- Forgetting
.valueon refs (the most common mistake) - Destructuring
reactive()objects loses reactivity (needtoRefs()) thisdoesn't exist in<script setup>watchbehavior differs (explicit source required vs Options API string watchers)computedreturns a ref, not a plain value
Migration strategy
The recommended approach is incremental, not a big-bang rewrite:
Nuxt Bridge first: install
@nuxt/bridgein your Nuxt 2 project. This gives you Vue 3 runtime with Nuxt 3 APIs (useFetch,useState,defineNuxtPlugin) while keeping your existing code running.Migrate state management: Vuex to Pinia. This can happen while still on Bridge.
Migrate components incrementally: convert from Options API to
<script setup>one component at a time. Both styles work side by side.Migrate data fetching: replace
asyncData/fetchwithuseFetch/useAsyncData.Migrate middleware and plugins: replace
contextpatterns with composables.Migrate modules: rewrite with
@nuxt/kitif you have custom modules.Remove Bridge: switch to full Nuxt 3, update
nuxt.config.ts, run final test pass.Remove deprecated APIs:
$listeners,$on/$offevent bus, filters,$set/$delete.
Testing at every step is critical. Add e2e tests for critical flows before starting the migration so you have a safety net.