Nuxt Upgrade Guide

Compare Nuxt versions and see exactly what changes, what requires action, and what you can start using.

→
28Changes
16Breaking
27Actions
0Features
3Migration tools
Analyze your project

Check your package.json

UpgradePath detects relevant tools and selects them automatically.

Local only
Analyzed locally in your browser. Nothing is uploaded.
Project setup

What does your project use?

Select the tools in your project to include relevant dependency updates in this guide.

Breaking Changes

Configuration uses defineNuxtConfig and ESM

Action required
breakinghigh impact

Nuxt 3 rewrites the configuration and build system. CommonJS configuration and legacy buildModules need migration.

Beforetypescript
module.exports = {
  buildModules: ['@nuxtjs/tailwindcss']
}
Aftertypescript
export default defineNuxtConfig({
  modules: ['@nuxtjs/tailwindcss']
})
Migration

Convert the config to ESM. Move compatible modules to modules and verify each module supports Nuxt 3.

Vue 3 replaces Vue 2

Action required
breakinghigh impact

Nuxt 3 uses Vue 3 and Vue Router 4. Vue 2 plugins, filters and removed APIs need review; the Options API remains supported.

Beforevue
<template>{{ price | currency }}</template>
<script>
export default { filters: { currency: v => '$' + v } }
</script>
Aftervue
<script setup>
const props = defineProps({ price: Number })
const formatted = computed(() => '$' + props.price)
</script>
<template>{{ formatted }}</template>
Migration

Update Vue dependencies together and replace filters with methods or computed values. Check third-party Vue compatibility.

asyncData and fetch migrate to SSR composables

Action required
breakinghigh impact

useAsyncData and useFetch transfer server-fetched data through the Nuxt payload. A direct $fetch in component setup can fetch again during hydration.

Beforevue
<script>
export default {
  async asyncData({ $axios }) {
    return { posts: await $axios.$get('/api/posts') }
  }
}
</script>
Aftervue
<script setup>
const { data: posts, error, status } = await useFetch('/api/posts')
</script>
Migration

Use useFetch for URLs or useAsyncData for custom handlers. Return serializable data and handle pending/error states.

Plugins use defineNuxtPlugin and provide

Action required
breakinghigh impact

The context/inject plugin signature is replaced by a Nuxt app plugin. Plugins in plugins/ are registered automatically.

Beforetypescript
export default (context, inject) => {
  inject('hello', name => 'Hello ' + name)
}
Aftertypescript
// plugins/hello.ts
export default defineNuxtPlugin(() => ({
  provide: { hello: (name: string) => 'Hello ' + name }
}))
// In component setup:
const { $hello } = useNuxtApp()
Migration

Return helpers through provide and use useNuxtApp to consume them. Name browser-only plugins with .client.ts.

Route middleware uses navigation return values

Action required
breakinghigh impact

defineNuxtRouteMiddleware receives to/from rather than Nuxt 2 context. Redirects and aborts must be returned.

Beforetypescript
export default function ({ store, redirect }) {
  if (!store.state.user) return redirect('/login')
}
Aftertypescript
export default defineNuxtRouteMiddleware(() => {
  const user = useState('user', () => null)
  if (!user.value) return navigateTo('/login')
})
Migration

Move middleware to the new signature, return navigateTo/abortNavigation and use .global.ts for global route middleware.

Dynamic route filenames use brackets

Action required
breakinghigh impact

Nuxt 2 underscore route conventions change to Vue Router 4-style bracket filenames.

Beforetext
pages/users/_id.vue
pages/_.vue
Aftertext
pages/users/[id].vue
pages/[...slug].vue
Migration

Rename dynamic/catch-all files and verify optional parameters, generated routes and direct SSR requests.

Layouts use slots and NuxtLayout/NuxtPage

Action required
breakinghigh impact

The Nuxt 2 Nuxt and NuxtChild components are replaced. Layout and page settings move to definePageMeta.

Beforevue
<!-- layouts/default.vue -->
<template><main><Nuxt /></main></template>
Aftervue
<!-- layouts/default.vue -->
<template><main><slot /></main></template>
<!-- app.vue -->
<template><NuxtLayout><NuxtPage /></NuxtLayout></template>
Migration

Use slot in layouts and NuxtPage for pages/nested pages. Wrap the root page in NuxtLayout when using layouts.

Runtime config separates server secrets from public values

Action required
breakinghigh impact

publicRuntimeConfig/privateRuntimeConfig become runtimeConfig with a public section. Public values are exposed to the browser.

Beforetypescript
export default {
  privateRuntimeConfig: { apiSecret: '' },
  publicRuntimeConfig: { apiBase: '/api' }
}
Aftertypescript
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: '', // NUXT_API_SECRET; server only
    public: { apiBase: '/api' } // NUXT_PUBLIC_API_BASE
  }
})
Migration

Keep secrets at the root and read configuration using useRuntimeConfig. Use matching NUXT_ environment variables at runtime.

Nitro replaces legacy serverMiddleware

Action required
breakinghigh impact

Nuxt 3 uses the standalone Nitro server with file-based API routes and H3 handlers.

Beforetypescript
// nuxt.config.js
export default { serverMiddleware: [
  { path: '/api/health', handler: '~/api/health.js' }
] }
// api/health.js
export default (req, res) => res.end('ok')
Aftertypescript
// server/api/health.get.ts
export default defineEventHandler(() => ({ status: 'ok' }))
Migration

Move Express/connect middleware to compatible Nitro handlers. Preserve authentication, status codes and response semantics.

Deploy the Nitro output rather than a Nuxt 2 server

Action required
breakinghigh impact

Node deployments run .output/server/index.mjs. Nuxt 2 nuxt start and .nuxt directory deployment instructions do not match Nitro.

Beforebash
npm run build
npx nuxt start
Afterbash
npm run build
node .output/server/index.mjs
Migration

Build for the intended Nitro preset and ship the complete .output. Check SSR pages, environment values, static assets and health endpoints.

Node.js minimum runtime increases

Action required
breakinghigh impact

Nuxt 4.0.0 requires Node.js ^20.19.0 or >=22.12.0. Historical Nuxt 3 runtime support does not carry over.

Beforejson
{ "engines": { "node": ">=18" } }
Afterjson
{ "engines": { "node": "^20.19.0 || >=22.12.0" } }
Migration

Update development, CI and production runtimes. Use a currently supported Node.js release satisfying the engine range.

Boolean refresh dedupe options are removed

Action required
breakinghigh impact

The deprecated boolean overload for refresh dedupe no longer works: true maps to cancel and false maps to defer.

Beforetypescript
await refresh({ dedupe: true })
await refresh({ dedupe: false })
Aftertypescript
await refresh({ dedupe: 'cancel' })
await refresh({ dedupe: 'defer' })
Migration

Use the explicit string behavior and test concurrent refresh requests.

Unhead 2 removes legacy head properties

Action required
breakinghigh impact

Unhead 2 removes vmid, hid, children, body and Promise inputs. Legacy configuration can lose tags or fail type checks.

Beforetypescript
useHead({
  script: [{ src: '/analytics.js', body: true }],
  meta: [{ hid: 'description', name: 'description', content: 'Guide' }]
})
Aftertypescript
useHead({
  script: [{ src: '/analytics.js', tagPosition: 'bodyClose' }],
  meta: [{ name: 'description', content: 'Guide' }]
})
Migration

Use supported tag properties and tagPosition for body tags. Resolve promises before calling useHead.

Route name and path leave meta

Action required
breakinghigh impact

The duplicate route.meta.name/path fields are no longer populated by Nuxt.

Beforetypescript
const name = route.meta.name
const path = route.meta.path
Aftertypescript
const name = route.name
const path = route.path
Migration

Read route.name and route.path directly and review custom route extensions.

Prerender routes move to Nitro configuration

Action required
breakinghigh impact

Legacy top-level generate.routes/exclude options are removed.

Beforetypescript
export default defineNuxtConfig({
  generate: { routes: ['/about'], exclude: ['/admin'] }
})
Aftertypescript
export default defineNuxtConfig({
  nitro: { prerender: { routes: ['/about'], ignore: ['/admin'] } }
})
Migration

Move route lists to nitro.prerender.routes/ignore. Inspect generated pages, canonical URLs and sitemap links.

window.__NUXT__ is removed after hydration

Action required
breakinghigh impact

Code that reads the global payload after hydration can fail. Nuxt app payload remains available through the app API.

Beforetypescript
const data = window.__NUXT__.data
Aftertypescript
const data = useNuxtApp().payload.data
Migration

Use useNuxtApp().payload in Nuxt context. Review analytics/custom integration code accessing the global.

Migration Tools

Head metadata moves from vue-meta to useHead

Action required
migrationmedium impact

Nuxt 3 uses Unhead. useHead/useSeoMeta support reactive SSR metadata; duplicate-tag identifiers such as hid are unnecessary.

Beforevue
<script>
export default {
  head: () => ({ meta: [{ hid: 'description', name: 'description', content: 'Upgrade guide' }] })
}
</script>
Aftervue
<script setup>
useSeoMeta({ title: 'Upgrade guide', description: 'Upgrade guide' })
useHead({ link: [{ rel: 'canonical', href: 'https://example.com/guide' }] })
</script>
Migration

Migrate page titles, descriptions and canonical links, then inspect server HTML. Do not expose server-only values in metadata.

SSR state must be request-safe

Action required
migrationmedium impact

Nuxt 3 has no built-in Vuex integration. useState creates shared SSR-safe state; module-scope refs can leak state between server requests.

Beforetypescript
// Shared across requests when imported by server code:
export const user = ref(null)
Aftertypescript
// Call from component setup or a Nuxt composable:
export const useUser = () => useState('user', () => null)
Migration

Migrate small state to useState or install compatible Pinia integration. Keep user-specific state inside Nuxt request context.

Application files default to app/

migrationmedium impact

Nuxt 4 adopts an app directory for application code. server, public, modules and shared remain at project root. Existing layouts are auto-detected for backward compatibility.

Beforetext
pages/
components/
composables/
server/
public/
Aftertext
app/pages/
app/components/
app/composables/
server/
public/
shared/
Migration

Review directory detection and custom aliases/tooling. If adopting app/, move application files and remember ~ points to app while ~~ points to project root.

Behavior Changes

Fetched data is shallow by default

Action required
behaviormedium impact

useAsyncData/useFetch return shallow data refs by default. Nested mutations do not trigger reactive updates as they did with deep refs.

Beforetypescript
const { data } = await useFetch('/api/profile')
data.value.preferences.theme = 'dark'
Aftertypescript
const { data } = await useFetch('/api/profile', { deep: true })
if (data.value) data.value.preferences.theme = 'dark'
Migration

Prefer immutable replacement; set deep: true per call when nested mutations are intentional and test dependent views.

Same data keys share state and consistent options

Action required
behaviormedium impact

Calls with the same useAsyncData key share data/error/status. Handler, deep, transform, pick, getCachedData and default must be consistent.

Beforetypescript
useAsyncData('users', () => $fetch('/api/users'), { deep: true })
useAsyncData('users', () => $fetch('/api/users'), { deep: false })
Aftertypescript
export const useUsers = () => useAsyncData(
  'users', () => $fetch('/api/users'), { deep: false }
)
// All consumers call useUsers().
Migration

Consolidate a key's options in one composable. Use distinct keys for genuinely different resources/options.

getCachedData also runs during refreshes

Action required
behaviormedium impact

Custom cached-data handlers are invoked for refresh/watch as well as initial fetch. Ignoring the cause can prevent an intended network refresh.

Beforetypescript
getCachedData: key => cache[key]
Aftertypescript
getCachedData: (key, nuxtApp, ctx) => {
  if (ctx.cause === 'refresh:manual' || ctx.cause === 'refresh:hook') return undefined
  return cache[key]
}
Migration

Use the context cause to bypass cached data on explicit refresh and review watch-triggered caching.

Initial data and error use undefined

Action required
behaviormedium impact

The initial empty value changes from null to undefined. Strict null-only checks can fail to recognize unloaded data.

Beforetypescript
if (data.value === null) {
  showPlaceholder()
}
Aftertypescript
if (data.value == null) {
  showPlaceholder()
}
Migration

Review initial defaults and checks; use nullish checks when both null and undefined represent an empty state.

pending reflects active request status

Action required
behaviormedium impact

pending is derived from status === pending. With immediate: false it starts false rather than treating idle as pending.

Beforevue
<script setup>
const { pending } = await useFetch('/api/items', { immediate: false })
</script>
<template><p v-if="pending">Not loaded yet</p></template>
Aftervue
<script setup>
const { status, execute } = await useFetch('/api/items', { immediate: false })
</script>
<template>
  <button v-if="status === 'idle'" @click="execute()">Load</button>
  <p v-if="status === 'pending'">Loading</p>
</template>
Migration

Use status to distinguish idle, pending, success and error; explicitly execute deferred requests.

clear restores the configured default

Action required
behaviormedium impact

Clearing async data now restores its default factory value instead of always setting data to undefined.

Beforetypescript
const { data, clear } = await useFetch('/api/items', { default: () => [] })
clear()
// Previously data reset to undefined.
Aftertypescript
const { data, clear } = await useFetch('/api/items', { default: () => [] })
clear()
// data is [] again; test length for an empty list.
Migration

Update reset logic and placeholder checks if relying on clear to remove a default value.

Prerendered data is shared across pages

Action required
behaviormedium impact

Payload data may be reused across prerendered pages. A static key for route-dependent content can produce the wrong page data.

Beforetypescript
const route = useRoute()
const { data } = await useAsyncData('article', () =>
  $fetch('/api/articles/' + route.params.slug)
)
Aftertypescript
const route = useRoute()
const key = computed(() => 'article:' + route.params.slug)
const { data } = await useAsyncData(key, () =>
  $fetch('/api/articles/' + route.params.slug)
)
Migration

Make keys identify the resource, including relevant route parameters, and verify prerendered HTML and payloads.

Error data is already parsed

Action required
behaviormedium impact

Nuxt error data is parsed rather than exposed as a JSON string. An extra JSON.parse can fail on error pages.

Beforetypescript
const details = JSON.parse(error.data)
Aftertypescript
const details = error.data
// Validate details before reading properties.
Migration

Consume error.data directly and validate its shape before rendering.

Override scanned page metadata in pages:resolved

Action required
behaviormedium impact

Nuxt scans page metadata after pages:extend. Metadata declared in pages may overwrite earlier hook changes.

Beforetypescript
export default defineNuxtConfig({
  hooks: { 'pages:extend': pages => {
    for (const page of pages) page.meta = { ...page.meta, layout: 'admin' }
  } }
})
Aftertypescript
export default defineNuxtConfig({
  hooks: { 'pages:resolved': pages => {
    for (const page of pages) page.meta = { ...page.meta, layout: 'admin' }
  } }
})
Migration

Use pages:resolved for overrides that must win over definePageMeta and test final routes.