Upgrade Guide
Upgrading Nuxt
Latest release
To upgrade Nuxt to the latest release, use the nuxt upgrade command.
npx nuxt upgrade
yarn nuxt upgrade
pnpm nuxt upgrade
bun x nuxt upgrade
deno x nuxt upgrade
Nightly Release Channel
To use the latest Nuxt build and test features before their release, read about the nightly release channel guide.
Testing Nuxt 5
Nuxt 5 is currently in development. Until the release, it is possible to test many of Nuxt 5's breaking changes from Nuxt version 4.2+.
Opting in to Nuxt 5
First, upgrade Nuxt to the latest release.
Then you can set your future.compatibilityVersion to match Nuxt 5 behavior:
export default defineNuxtConfig({
future: {
compatibilityVersion: 5,
},
})
When you set your future.compatibilityVersion to 5, defaults throughout your Nuxt configuration will change to opt in to Nuxt v5 behavior, including:
- Vite Environment API: Uses the new Vite Environment API for improved build configuration
- Normalized Page Names: Page component names will match their route names for consistent
<KeepAlive>behavior clearNuxtStateresets to defaults:clearNuxtStatewill reset state to its initial value instead of setting it toundefined- Non-async
callHook:callHookmay returnvoidinstead of always returning aPromise - Comment node placeholders: Client-only components use comment nodes instead of
<div>as SSR placeholders, fixing a scoped styles hydration issue - Other Nuxt 5 improvements and changes as they become available
future.compatibilityVersion: 5.Breaking or significant changes will be noted below along with migration steps for backward/forward compatibility.
Migration to Vite Environment API
🚦 Impact Level: Medium
What Changed
Nuxt 5 migrates to Vite 6's new Environment API, which formalizes the concept of environments and provides better control over configuration per environment.
Previously, Nuxt used separate client and server Vite configurations. Now, Nuxt uses a shared Vite configuration with environment-specific plugins that use the applyToEnvironment() method to target specific environments.
experimental.viteEnvironmentApi option has been removed.Key changes:
- Deprecated environment-specific
extendViteConfig(): Theserverandclientoptions inextendViteConfig()are deprecated and will show warnings when used. - Changed plugin registration: Vite plugins registered with
addVitePlugin()and only targeting one environment (by passingserver: falseorclient: false) will not have theirconfigorconfigResolvedhooks called. - Shared configuration: The
vite:extendConfigandvite:configResolvedhooks now work with a shared configuration rather than separate client/server configs.
Reasons for Change
The Vite Environment API provides:
- Better consistency between development and production builds
- More granular control over environment-specific configuration
- Improved performance and plugin architecture
- Support for custom environments beyond just client and server
Migration Steps
1. Migrate to use Vite plugins
We would recommend you use a Vite plugin instead of extendViteConfig, vite:configResolved and vite:extendConfig.
// Before
extendViteConfig((config) => {
config.optimizeDeps.include.push('my-package')
}, { server: false })
nuxt.hook('vite:extendConfig' /* or vite:configResolved */, (config, { isClient }) => {
if (isClient) {
config.optimizeDeps.include.push('my-package')
}
})
// After
addVitePlugin(() => ({
name: 'my-plugin',
config (config) {
// you can set global vite configuration here
},
configResolved (config) {
// you can access the fully resolved vite configuration here
},
configEnvironment (name, config) {
// you can set environment-specific vite configuration here
if (name === 'client') {
config.optimizeDeps ||= {}
config.optimizeDeps.include ||= []
config.optimizeDeps.include.push('my-package')
}
},
applyToEnvironment (environment) {
return environment.name === 'client'
},
}))
2. Migrate Vite plugins to use environments
Instead of using addVitePlugin with server: false or client: false, you can instead use the new applyToEnvironment hook within your plugin.
// Before
addVitePlugin(() => ({
name: 'my-plugin',
config (config) {
config.optimizeDeps.include.push('my-package')
},
}), { client: false })
// After
addVitePlugin(() => ({
name: 'my-plugin',
config (config) {
// you can set global vite configuration here
},
configResolved (config) {
// you can access the fully resolved vite configuration here
},
configEnvironment (name, config) {
// you can set environment-specific vite configuration here
if (name === 'client') {
config.optimizeDeps ||= {}
config.optimizeDeps.include ||= []
config.optimizeDeps.include.push('my-package')
}
},
applyToEnvironment (environment) {
return environment.name === 'client'
},
}))
Migration to Vite 8
🚦 Impact Level: Medium
What Changed
Nuxt 5 upgrades from Vite 7 to Vite 8, which replaces esbuild and Rollup with Rolldown as the underlying bundler. This brings significantly faster builds but includes several breaking changes.
future.compatibilityVersion: 5. If you want to test Vite 8 compatibility ahead of time, you can add a "vite": "^8.0.0-beta.15" resolution override in your package.json.Most of the migration is handled by Nuxt internally, but there are some user-facing changes to be aware of:
vite.esbuildandvite.optimizeDeps.esbuildOptionsare deprecated in favour ofvite.oxcandvite.optimizeDeps.rolldownOptions. Vite 8 converts these automatically for now, but they will be removed in the future.build.rollupOptionsis deprecated in favour ofbuild.rolldownOptions.- CommonJS interop behaviour has changed. If you import CJS modules, review the Vite 8 migration guide for details.
Migration to Nitro v3
🚦 Impact Level: Significant
What Changed
Nuxt 5 upgrades to Nitro v3, which is a major rewrite of the server engine. Nitro v3 is built on srvx and h3 v2, adopting Web standard Request/Response APIs throughout. This brings performance improvements and a more consistent API, but includes several breaking changes to server-side code.
The sections below highlight changes that are most relevant to Nuxt application developers and module authors.
Package and Import Path Changes
The nitropack package has been renamed to nitro. All import paths have changed:
| Before | After |
|---|---|
nitropack | nitro |
nitropack/types | nitro/types |
nitropack/runtime | nitro |
h3 (for server utilities) | nitro/h3 |
Auto-imports within server routes (defineEventHandler, getQuery, readBody, useRuntimeConfig, etc.) continue to work without changes.
If you have explicit imports in server code, update them:
- import { defineEventHandler, getQuery } from 'h3'
+ import { defineEventHandler, getQuery } from 'nitro/h3'
For module authors, type augmentations must target the new module path:
- declare module 'nitropack/types' {
+ declare module 'nitro/types' {
interface NitroRouteRules {
myModule?: { /* ... */ }
}
}
Error Handling: status/statusText replace statusCode/statusMessage
h3 v2 renames the error properties to align with Web standards:
createError({
- statusCode: 404,
- statusMessage: 'Not Found',
+ status: 404,
+ statusText: 'Not Found',
})
In server routes, the error class is now HTTPError (replacing createError from h3):
- import { createError } from 'h3'
+ import { HTTPError } from 'nitro/h3'
export default defineEventHandler(() => {
- throw createError({ statusCode: 400, statusMessage: 'Bad request' })
+ throw new HTTPError({ status: 400, statusText: 'Bad request' })
})
app/ directory), Nuxt's createError composable continues to work and is the recommended way to throw errors.Server Event API Changes (h3 v2)
The H3Event object now uses Web standard APIs:
Request properties:
- event.path // string
+ event.url.pathname // URL object - use .pathname, .search, .hash
- event.method // string
+ event.req.method // via Web Request object
- event.node.req.headers // Node.js IncomingHttpHeaders
+ event.req.headers // Web Headers API (.get(), .set(), .has())
Response properties:
- event.node.res.statusCode = 200
+ event.res.status = 200
- event.node.res.statusMessage = 'OK'
+ event.res.statusText = 'OK'
- setResponseHeader(event, 'x-custom', 'value')
+ event.res.headers.set('x-custom', 'value')
- appendResponseHeader(event, 'set-cookie', cookie)
+ event.res.headers.append('set-cookie', cookie)
useRuntimeConfig() No Longer Accepts event
In Nitro v3, useRuntimeConfig() no longer requires (or accepts) an event argument in server routes:
export default defineEventHandler((event) => {
- const config = useRuntimeConfig(event)
+ const config = useRuntimeConfig()
})
Route Rules: statusCode Renamed to status
If you define redirect route rules, the property name has changed:
export default defineNuxtConfig({
routeRules: {
'/old-page': {
- redirect: { to: '/new-page', statusCode: 302 },
+ redirect: { to: '/new-page', status: 302 },
},
},
})
For Module Authors: Additional Changes
- Nitro plugin imports: Use
import { definePlugin } from 'nitro'for explicit imports (auto-imports still work). - Runtime hooks:
nitroApp.hooks.hook('beforeResponse', ...)andnitroApp.hooks.hook('afterResponse', ...)have been replaced bynitroApp.hooks.hook('response', ...). getRouteRules()fromnitro/app: On the server, the Nitro helper changed fromgetRouteRules(event)togetRouteRules(method, pathname), which returns{ routeRules }.
Removal of experimental.externalVue
🚦 Impact Level: Minimal
What Changed
The experimental.externalVue option has been removed. Vue compiler dependencies (@babel/parser, @vue/compiler-core, @vue/compiler-dom, @vue/compiler-ssr, estree-walker) are now always replaced with mock proxies in the server bundle when vue.runtimeCompiler is not enabled.
Reasons for Change
With the migration to Nitro v3, all dependencies are bundled into the server output by default (unlike Nitro v2, which externalized node_modules). The externalVue option was originally designed to keep Vue as an external dependency, which was needed to avoid multiple copies of Vue from being bundled, but since Nitro v3 bundles everything regardless, the option became a no-op.
Vue's server builds include the full compiler toolchain, pulling @babel/parser (465KB) and other compiler packages into the server bundle unnecessarily. These compiler packages are only needed when vue.runtimeCompiler is enabled for runtime template compilation.
By always mocking these compiler dependencies, the default server bundle size is reduced by approximately 860KB (~59%).
Migration Steps
If you previously set experimental.externalVue explicitly, you should now remove it.
export default defineNuxtConfig({
experimental: {
- externalVue: false,
},
})
vue.runtimeCompiler: true, the real compiler packages are still included as before.@vitejs/plugin-vue-jsx Is Now Optional
🚦 Impact Level: Minimal
What Changed
@vitejs/plugin-vue-jsx is no longer installed by default with @nuxt/vite-builder. It is now an optional peer dependency that is loaded on demand only when a .jsx or .tsx file is encountered during the build.
If your project uses JSX/TSX components, Nuxt will automatically detect this and prompt you to install the package.
Reasons for Change
The @vitejs/plugin-vue-jsx plugin pulls in a significant dependency tree (Babel, @vue/babel-plugin-jsx, etc.) that is unnecessary for projects that don't use JSX. Making it optional reduces the default install size and speeds up dependency resolution for the majority of Nuxt projects.
Migration Steps
If your project uses .jsx or .tsx files, add @vitejs/plugin-vue-jsx as a dev dependency:
npm install -D @vitejs/plugin-vue-jsx
yarn add -D @vitejs/plugin-vue-jsx
pnpm add -D @vitejs/plugin-vue-jsx
bun add -D @vitejs/plugin-vue-jsx
Alternatively, Nuxt will prompt you to install it automatically the first time a JSX/TSX file is processed during development.
If your project does not use JSX, no changes are needed.
Removal of Legacy _renderResponse Support
🚦 Impact Level: Minimal
What Changed
ssrContext._renderResponse is no longer checked as a fallback. Only the internal ssrContext['~renderResponse'] (set by Nuxt's own router composable) is used.
Reasons for Change
The _renderResponse property on ssrContext was kept as a backward-compatibility fallback after #33896 migrated the internal API to ~renderResponse. The TODO comments indicated it should be removed in Nuxt v5.
Migration Steps
If you were setting ssrContext._renderResponse directly (which was never a public API), use ssrContext['~renderResponse'] instead. The Nuxt router composable already uses the new property, so no changes are needed if you're going through navigateTo or route middleware.
Non-Async callHook
🚦 Impact Level: Minimal
What Changed
With the upgrade to hookable v6, callHook may now return void instead of always returning Promise<void>. This is a significant performance improvement that avoids unnecessary Promise allocations when there are no registered hooks or all hooks are synchronous.
By default (with compatibilityVersion: 4), Nuxt wraps callHook with Promise.resolve() so that existing .then() and .catch() chaining continues to work. With compatibilityVersion: 5, this wrapper is removed.
Reasons for Change
Hookable v6's callHook is 20-40x faster because it avoids creating a Promise when one is not needed. This benefits applications with many hook call sites.
Migration Steps
If you or your modules use callHook with .then() or .catch() chaining, switch to await:
- nuxtApp.callHook('my:hook', data).then(() => { ... })
+ await nuxtApp.callHook('my:hook', data)
- nuxtApp.hooks.callHook('my:hook', data).catch(err => { ... })
+ try { await nuxtApp.hooks.callHook('my:hook', data) } catch (err) { ... }
future.compatibilityVersion: 5 (see Testing Nuxt 5) or by enabling it explicitly with experimental.asyncCallHook: false.Alternatively, you can ensure callHook always returns a Promise with:
export default defineNuxtConfig({
experimental: {
asyncCallHook: true,
},
})
Client-Only Comment Placeholders
🚦 Impact Level: Minimal
What Changed
With compatibilityVersion: 5, client-only components (.client.vue files and createClientOnly() wrappers) now render an HTML comment (<!--placeholder-->) on the server instead of an empty <div> element.
Reasons for Change
When the placeholder <div> and the actual component root share the same tag name, Vue's runtime skips re-applying setScopeId during hydration. This causes scoped styles to be missing after the component mounts. Using a comment node avoids the tag name collision entirely.
Migration Steps
If you rely on the placeholder <div> to inherit attributes (class, style, etc.) for layout purposes (e.g., reserving space to prevent layout shift), wrap the component in <ClientOnly> with a #fallback slot instead:
- <MyComponent class="placeholder" style="min-height: 200px" />
+ <ClientOnly>
+ <MyComponent />
+ <template #fallback>
+ <div class="placeholder" style="min-height: 200px"></div>
+ </template>
+ </ClientOnly>
future.compatibilityVersion: 5 (see Testing Nuxt 5) or by enabling it explicitly with experimental.clientNodePlaceholder: true.Alternatively, you can revert to the previous <div> placeholder behavior with:
export default defineNuxtConfig({
experimental: {
clientNodePlaceholder: false,
},
})