Upgrade Guide

Learn how to upgrade to the latest Nuxt version.

Upgrading Nuxt

Latest release

To upgrade Nuxt to the latest release, use the nuxt upgrade command.

npx 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:

nuxt.config.ts
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:

This section is subject to change until the final release, so please check back here regularly if you are testing Nuxt 5 using future.compatibilityVersion: 5.

Breaking or significant changes will be noted below along with migration steps for backward/forward compatibility.

process.* Type Augmentation Removed

๐Ÿšฆ Impact Level: Minimal

What Changed

Nuxt no longer augments NodeJS.Process with browser, client, dev, server, and test. Build-time defines for those flags remain for compatibility, but TypeScript will not treat them as known properties.

Prefer import.meta.*. Those flags are replaced at build time and stay tree-shakeable.

Migration Steps

Replace legacy checks in your app code, modules, and libraries:

// Before
// eslint-disable-next-line nuxt/prefer-import-meta
if (process.server) {
  /* ... */
}

// After
if (import.meta.server) {
  /* ... */
}

The nuxt/prefer-import-meta ESLint rule flags remaining process.* usage.

Case-Sensitive Routing

๐Ÿšฆ Impact Level: Minimal

What Changed

With compatibilityVersion: 5, page routes match URLs case-sensitively, consistent with Nitro. For example, /About no longer matches pages/about.vue.

Migration Steps

Update links to use the same casing as their page routes. To keep case-insensitive matching:

nuxt.config.ts
export default defineNuxtConfig({
  router: {
    options: {
      sensitive: false,
    },
  },
})

jiti Is No Longer Bundled

๐Ÿšฆ Impact Level: Medium

What Changed

Nuxt no longer depends on jiti. Files loaded outside the bundler (nuxt.config.ts, files in modules/, and layer configs) are now imported by the runtime itself.

You can still write your config in TypeScript. Nuxt 5 requires Node 22.19 or later, where type stripping is on by default, so nuxt.config.ts and TypeScript modules load natively. Two things the runtime does not do, which jiti used to paper over, are guess file extensions and compile TypeScript syntax that emits code.

Both would otherwise only surface when Nuxt loads the file, so the generated node tsconfig now describes the environment the way the runtime sees it (module and moduleResolution set to nodenext, plus erasableSyntaxOnly) and TypeScript reports both up front.

That covers nuxt.config, modules/ and layer configs only. Your app and shared/ code goes through Vite and resolves the way it always has. If you need the previous behaviour, override it:

nuxt.config.ts
export default defineNuxtConfig({
  typescript: {
    nodeTsConfig: {
      compilerOptions: {
        module: 'preserve',
        moduleResolution: 'bundler',
        erasableSyntaxOnly: false,
      },
    },
  },
})

Reasons for Change

jiti was pulled into every Nuxt install to load a handful of files, most of which the runtime can now load unaided. Removing it makes a default install smaller.

Migration Steps

1. Add file extensions to relative imports.

Relative imports in nuxt.config.ts, modules/, and layer configs need an explicit extension:

nuxt.config.ts
- import { myPlugin } from './build/my-plugin'
+ import { myPlugin } from './build/my-plugin.ts'

TypeScript reports a missing extension as TS2835. Note that its quick fix suggests ./build/my-plugin.js; write the extension the file actually has (.ts), which Node resolves directly.

Bare package imports (import { defu } from 'defu') are unaffected.

2. Use erasable TypeScript syntax.

Type annotations are erased, but syntax that emits runtime code cannot be. In config and module files, replace:

  • enum Foo {} with a const object
  • namespace / module blocks with plain exports
  • constructor parameter properties (constructor(private x: string) {}) with an explicit assignment
  • experimental decorators

TypeScript reports all of these for you as TS1294.

3. If you publish a layer or module, ship compiled JavaScript.

This one is specific to packages. The runtime refuses to strip types from any file inside node_modules, whatever the configuration, so a published entrypoint written in TypeScript cannot be loaded natively however new the Node version is. Build to JavaScript before publishing, and if your package ships a nuxt.config, emit it as nuxt.config.mjs. Otherwise every consuming project has to install jiti.

This does not apply to layers inside your own project: layers/*/nuxt.config.ts loads natively.

4. Install jiti if you still need it.

jiti is now an optional peer dependency. Install it and Nuxt will pick it up automatically as a fallback whenever the runtime cannot load a file on its own:

npm i -D jiti

A nuxt.schema file always needs jiti, whatever Node version you are on: its JSDoc annotations are read by an import-time transform rather than by importing the file.

One smaller change comes with this: PostCSS plugins named in postcss.plugins are now resolved by the runtime, so a plugin name that only resolves through a Nuxt alias entry no longer loads. Use the package name, or a path.

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.

The Vite Environment API is always enabled in Nuxt 5. The experimental.viteEnvironmentApi option has been removed.

Key changes:

  1. Deprecated environment-specific extendViteConfig(): The server and client options in extendViteConfig() are deprecated and will show warnings when used.
  2. Changed plugin registration: Vite plugins registered with addVitePlugin() and only targeting one environment (by passing server: false or client: false) will not have their config or configResolved hooks called.
  3. Shared configuration: The vite:extendConfig and vite:configResolved hooks 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'
  },
}))
Learn more about Vite's Environment API

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.

Unlike the Vite Environment API migration, this change cannot be opted into early with 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.esbuild and vite.optimizeDeps.esbuildOptions are deprecated in favour of vite.oxc and vite.optimizeDeps.rolldownOptions. Vite 8 converts these automatically for now, but they will be removed in the future.
  • build.rollupOptions is deprecated in favour of build.rolldownOptions.
  • CommonJS interop behaviour has changed. If you import CJS modules, review the Vite 8 migration guide for details.
See the full Vite 8 migration guide for all breaking changes and migration steps.

Server Imports Move to nuxt/server

๐Ÿšฆ Impact Level: Medium

What Changed

Nuxt 5 ships nuxt/server, an import surface for the server utilities server code reaches for most: defineEventHandler, createError, getQuery, readBody, the cookie and header helpers, sendRedirect, getRouteRules and useRuntimeConfig. It replaces @nuxt/nitro-server/h3, which is deprecated.

Which server runtime is under your application depends on the configured server.builder, and importing from nitro/h3 pins your code to one of them and to its major version. nuxt/server does not, so one file can serve Nuxt 4.6 (running either nitropack v2 or Nitro v3) and Nuxt 5.

server/api/hello.ts
- import { defineEventHandler, getQuery } from 'nitro/h3'
+ import { defineEventHandler, getQuery } from 'nuxt/server'

  export default defineEventHandler((event) => {
    return getQuery(event)
  })

Errors take the same shape as the ones the Vue part of your app constructs, and come from the same place:

- import { HTTPError } from 'nitro/h3'
+ import { createError } from 'nuxt/server'

  export default defineEventHandler(() => {
-   throw new HTTPError({ status: 400, statusText: 'Bad request' })
+   throw createError({ status: 400, statusText: 'Bad request' })
  })

Server auto-imports resolve to nuxt/server too, so a project that leaves them on needs no change.

Migration Steps

  1. Replace nitro/h3 and @nuxt/nitro-server/h3 imports with nuxt/server, for the utilities in the surface.
  2. Leave the rest on nitro/h3. nuxt/server is not a re-export of h3, so helpers such as readValidatedBody and handleCors stay where they are.
  3. Nothing you reach off event has to change: under the default Nitro builder it is still h3's H3Event.
Learn what the surface covers, and what it means for module authors.

Sessions Come From nuxt/server

๐Ÿšฆ Impact Level: Medium

What Changed

useSession, getSession, updateSession and clearSession are now part of nuxt/server, and are what the auto-imported names resolve to. They seal the session with iron-webcrypto on web-standard Request and Response, so they run under any server.builder rather than only under Nitro.

A session issued by the h3 helpers does not unseal with these, and the default cookie name is nuxt-session rather than h3. Everyone signed in before the upgrade gets a fresh, empty session once.

A password is no longer required. Without one, the session is sealed with a secret derived from appSecret, which NUXT_APP_SECRET sets:

server/api/me.ts
  export default defineEventHandler(async (event) => {
-   const session = await useSession<{ user?: string }>(event, {
-     password: process.env.NUXT_SESSION_PASSWORD!,
-   })
+   const session = await useSession<{ user?: string }>(event)
    return { user: session.data.user }
  })

Passing a password of at least 32 characters still seals with that instead.

To keep h3's implementation, import it explicitly:

server/api/me.ts
+ import { useSession } from 'nitro/h3'

  export default defineEventHandler(async (event) => {
    const session = await useSession(event, { password: process.env.NUXT_SESSION_PASSWORD! })
    return { user: session.data.user }
  })

Migration Steps

  1. Plan for existing sessions ending: users are signed out once, on the deploy that upgrades them.
  2. Where you relied on h3-specific options (sessionHeader, crypto, seal), either import useSession from nitro/h3 or move to the nuxt/server equivalents: name, maxAge and cookie.
  3. Set NUXT_APP_SECRET in every deployed environment if you drop password; in development Nuxt generates one.
  4. Set the cookie name to h3 only if you also keep h3's helpers; the two implementations should not share a cookie.
Learn what the session helpers cover.

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.

We are still working on Nitro v3 integration so you should expect further changes, as well as additional work to make migration more straightforward.
Read the Nitro v3 beta announcement for a full overview.
See the full Nitro v3 migration guide for all breaking changes.

The sections below highlight changes that are most relevant to Nuxt application developers and module authors. You don't have to make all of them before you upgrade: see Nitro v2 Compatibility for the layer that keeps older server code running in the meantime.

Package and Import Path Changes

The nitropack package has been renamed to nitro. All import paths have changed:

BeforeAfter
nitropacknitro
nitropack/typesnitro/types
nitropack/runtimenitro
h3 (for server utilities)nitro/h3

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 NitroRuntimeConfig {
      myModule?: { /* ... */ }
    }
  }

Route rules are augmented on a different module again. See Route Rule Types Move to h3/rules.

Server Auto-Imports Are Now Opt-In

Nitro v3 removed its auto-import support, so utilities such as defineEventHandler, getQuery, readBody and useRuntimeConfig are no longer global in server code. Nuxt still provides them, but in Nuxt 5 they are off by default.

Add explicit imports to your server code. Prefer nuxt/server, which is what the auto-imports resolve to:

server/api/hello.ts
+ import { defineEventHandler, getQuery } from 'nuxt/server'
+
  export default defineEventHandler((event) => {
    return getQuery(event)
  })

Or keep the previous behaviour while you migrate:

nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    nitroAutoImports: true,
  },
})

This applies only to the utilities Nitro and h3 provide. Your own exports from server/utils/ and shared/utils/ are still auto-imported.

#imports Is Deprecated in Server Code, in Favour of #imports/server

Server code should import from #imports/server:

server/api/hello.ts
- import { defineEventHandler } from '#imports'
+ import { defineEventHandler } from '#imports/server'

  export default defineEventHandler(() => ({ hello: true }))

#imports still resolves when your server code runs, so an unmigrated project keeps working, but it is no longer typed: TypeScript reports it as unresolved until you move to #imports/server.

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' })
  })
In the Vue part of your app (the app/ directory), Nuxt's createError composable continues to work and is the recommended way to throw errors.

createError No Longer Returns an HTTPError Instance

NuxtError is now its own class rather than a subclass of h3's HTTPError, so instanceof HTTPError no longer matches an error created with Nuxt's createError. Everything else is unchanged: the error keeps the same properties, serialises the same way, and is still mapped to the right HTTP response during SSR.

If you narrow errors by class, use one of the predicates instead:

- if (error instanceof HTTPError) {
+ if (HTTPError.isError(error)) {
    // handles both `new HTTPError()` and Nuxt's `createError()`
  }
+ import { isNuxtError } from '#app'
+
+ if (isNuxtError(error)) {
+   // narrows to errors created with Nuxt's `createError`
+ }

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 },
      },
    },
  })

Nuxt applies statusCode as status for now and warns, so an unmigrated rule keeps its status rather than silently falling back to the default. The fallback will be removed.

Cached Route Rules Ignore Query Parameters

Cached routes (cache, swr, isr) now key on the path only, and the query string is dropped before the handler runs. Set allowQuery to keep it, either as true or as a list of parameter names:

export default defineNuxtConfig({
  routeRules: {
    '/products': { cache: { swr: true, maxAge: 60, allowQuery: ['page'] } },
  },
})

Route Rule Types Move to h3/rules

Nitro v3 builds route rules on h3, so custom route rules are declared there rather than on nitro/types. NitroRouteConfig and NitroRouteRules are still exported as deprecated aliases, but they are now type aliases rather than interfaces, so augmenting them fails with TS2300: Duplicate identifier.

There are two interfaces to declare, and they are separate on purpose. RouteRuleConfig is what a rule looks like in nuxt.config, and RouteRules is what a matched rule looks like at runtime:

- declare module 'nitropack/types' {
-   interface NitroRouteConfig {
+ declare module 'h3/rules' {
+   interface RouteRuleConfig {
      myModule?: { enabled: boolean }
    }
-   interface NitroRouteRules {
+   interface RouteRules {
      myModule?: { enabled: boolean }
    }
  }

A rule that nothing declares now reads back as unknown rather than any, so it needs a cast at the point of use:

- const enabled = rules.myUndeclaredRule
+ const enabled = rules.myUndeclaredRule as boolean | undefined

The Server tsconfig.json Is Generated by Nuxt

Nuxt now generates .nuxt/tsconfig.server.json itself, as one of the tsconfigs it writes per environment, rather than delegating it to the server builder. If you extended it through Nitro, use the Nuxt option instead:

  export default defineNuxtConfig({
-   nitro: {
-     typescript: {
-       tsConfig: { compilerOptions: { /* ... */ } },
-     },
-   },
+   typescript: {
+     serverTsConfig: { compilerOptions: { /* ... */ } },
+   },
  })

typescript.serverTsConfig already existed in Nuxt 4 and was kept in sync with nitro.typescript.tsConfig, so this is a no-op if you were already using it.

For Module Authors: Additional Changes

  • Nitro plugin imports: Use import { definePlugin } from 'nitro', which is now required by default. See Server Auto-Imports Are Now Opt-In.
  • Route rule helpers: basicAuth route rules are replaced by middleware, and a new cors rule replaces manual CORS wiring. See the Nitro migration guide for the runtime details.
  • Runtime hooks: nitroApp.hooks.hook('beforeResponse', ...) and nitroApp.hooks.hook('afterResponse', ...) have been replaced by nitroApp.hooks.hook('response', ...).
  • getRouteRules() from nitro/app: On the server, the Nitro helper changed from getRouteRules(event) to getRouteRules(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,
    },
  })
If you use vue.runtimeCompiler: true, the real compiler packages are still included as before.

experimental.parseErrorData Is No Longer Configurable

๐Ÿšฆ Impact Level: Minimal

What Changed

The experimental.parseErrorData option is deprecated, and on Nuxt 5 it is forced on. Setting it to false logs a warning and is otherwise ignored, so error.data on the error page is always the value you passed to createError.

On compatibilityVersion: 4 the option still works and false still gives you a stringified error.data, so the behaviour only changes when you opt in to Nuxt 5.

Reasons for Change

When Nuxt renders an error page it fetches /__nuxt_error internally, passing the error along with the request. That used to spread the error across one query parameter per field, and because query values are always strings, error.data arrived as a string and had to be parsed back. parseErrorData existed to opt out of that parsing.

The error is now sent as a single JSON-encoded parameter, so error.data keeps its original shape and is never stringified. Values also keep their types, so status is a number and booleans are booleans rather than the strings 'true' and 'false'. With nothing stringifying error.data, the option only survives to re-stringify it for apps that still expect a string.

Migration Steps

Remove the option:

  export default defineNuxtConfig({
    experimental: {
-     parseErrorData: false,
    },
  })

If you set it to false in order to keep parsing error.data yourself, drop that parsing too, as error.data now keeps whatever value you passed to createError:

  <script setup lang="ts">
  import type { NuxtError } from '#app'

  const props = defineProps({
    error: Object as () => NuxtError
  })

- const data = JSON.parse(props.error.data)
+ const data = props.error.data
  </script>

@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

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.

giget Is Now Optional (Remote Layers)

๐Ÿšฆ Impact Level: Minimal

What Changed

giget is no longer installed by default. It is now an optional peer dependency of @nuxt/kit, needed only to download a layer that extends names by remote URL:

nuxt.config.ts
export default defineNuxtConfig({
  extends: ['github:my-org/my-theme'],
})

Local layers, layers in ~~/layers/, and layers installed as packages are unaffected.

Reasons for Change

Most projects never extend from a remote source, so the downloader was being installed for everyone to serve a small minority. Downloading a layer at config-resolution time is also the least reproducible way to consume one: the fetch happens outside your package manager, so it is not in your lockfile and, unless you pin it yourself, not pinned to a revision.

Migration Steps

Preferred: move the layer into package.json. Every major package manager understands git URLs, so a remote layer can be a normal dependency. This puts it in your lockfile, pins it to an exact commit, and installs it alongside everything else:

package.json
{
  "devDependencies": {
    "my-theme": "github:my-org/my-theme#4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b"
  }
}

Then extend from it by package name:

nuxt.config.ts
export default defineNuxtConfig({
  extends: ['my-theme'],
})

The part after # is any commit-ish, so a tag (#v1.2.0) or #semver:^1.2.0 works too; a full commit SHA is the only form that is genuinely immutable.

Otherwise, install giget and keep using a remote extends entry:

npm install -D giget

If a remote layer is resolved without giget present, Nuxt reports which extends entry needs it.

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.

This affects both build-time Nuxt hooks (used by Nuxt modules) and runtime Nuxt hooks (which you might use in your application code).

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) { ... }
You can test this feature early by setting 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:

nuxt.config.ts
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>
You can test this feature early by setting 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:

nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    clientNodePlaceholder: false,
  },
})

Stricter Side-Effect Imports

๐Ÿšฆ Impact Level: Minimal

What Changed

With compatibilityVersion: 5, Nuxt's generated tsconfig.json enables noUncheckedSideEffectImports. This is a default in TypeScript 7, so adopting it early keeps your project aligned ahead of that upgrade.

With this option on, a side-effect-only import (import './setup') that TypeScript cannot resolve to a module is now a type error, whereas it was previously ignored. This only affects type-checking (nuxt typecheck and your editor), not runtime behavior.

Reasons for Change

Unresolved side-effect imports were silently ignored, so a typo or a deleted file could pass type-checking. Flagging them catches these mistakes and matches the TypeScript 7 default.

Migration Steps

If type-checking now errors on a side-effect import of a non-code asset (for example import '~/assets/styles.css'), add an ambient module declaration so TypeScript knows the import is valid:

types.d.ts
declare module '*.css' {}
You can revert to the previous behavior by disabling the option in your nuxt.config:
nuxt.config.ts
export default defineNuxtConfig({
  typescript: {
    tsConfig: {
      compilerOptions: {
        noUncheckedSideEffectImports: false,
      },
    },
  },
})

Vue Options API Disabled by Default

๐Ÿšฆ Impact Level: Minimal

What Changed

With compatibilityVersion: 5, Nuxt sets Vue's __VUE_OPTIONS_API__ feature flag to false, which compiles Vue's Options API runtime out of the client bundle.

Reasons for Change

The Options API runtime ships in every client bundle even though most Nuxt applications are written with the Composition API and <script setup>. Dropping it shrinks the client bundle (around 6 kB minified / 2 kB gzipped on a minimal app).

Migration Steps

If any of your components (or a dependency's components) use the Options API (export default { data() {}, methods: {}, ... }), re-enable it in your nuxt.config:

nuxt.config.ts
export default defineNuxtConfig({
  vue: {
    optionsApi: true,
  },
})
defineNuxtComponent is unaffected: its asyncData and head options are handled through setup() rather than the Vue Options API, so it works regardless of this flag.

Typed Pages Enabled by Default

๐Ÿšฆ Impact Level: Minimal

What Changed

With compatibilityVersion: 5, experimental.typedPages is enabled by default. Nuxt generates typed route names and paths from your pages/ directory, so composables like useRoute, navigateTo, <NuxtLink> and router.push are type-checked against your actual routes.

Reasons for Change

Typed routing catches broken links and stale route names at type-check time rather than at runtime, and it is now native to vue-router, so it is a sensible default.

Migration Steps

If you reference routes that don't exist (for example a typo in a to prop, or a route defined dynamically outside the pages/ directory), type-checking will now error. Fix the reference, or extend the generated route types for routes you add at runtime.

You can test this feature early by setting future.compatibilityVersion: 5 (see Testing Nuxt 5) or by enabling it explicitly with experimental.typedPages: true.

Alternatively, you can opt out and revert to the previous behavior with:

nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    typedPages: false,
  },
})

Typed $fetch Rebuilt on Generated Route Types

๐Ÿšฆ Impact Level: Medium

What Changed

$fetch and useFetch are typed from the routes Nuxt's server builder reports, rather than from nitro's InternalApi interface. As well as response types, a body, query or headers the matching handler validates are now enforced on the call, and a method the route does not handle is rejected.

Two things follow from this:

  • Routes you augmented by hand on nitro's InternalApi are no longer picked up. Augment ServerRoutes from @nuxt/schema instead.
  • The params option is no longer accepted by $fetch or useFetch. It was an alias for query.

Reasons for Change

Nitro v3 dropped typed fetch, and the old implementation did not scale past about 300 routes. The new generated route types cost the same to check whatever the route count, and carry enough information to type the request as well as the response. params is dropped so the name is free for named route parameters later.

Migration Steps

Move any params option to query:

  const { data } = await useFetch('/api/search', {
-   params: { q: 'nuxt' },
+   query: { q: 'nuxt' },
  })

Move any manual route augmentation from InternalApi to ServerRoutes:

shared/server-routes.d.ts
- declare module 'nitropack' {
-   interface InternalApi {
-     '/api/hello': { get: { message: string } }
-   }
- }
+ import type { Endpoint } from 'nuxt/app'
+
+ declare module '@nuxt/schema' {
+   interface ServerRoutes {
+     '/api/hello': {
+       [Endpoint]: {
+         GET: { response: { message: string } }
+       }
+     }
+   }
+ }
Read more about typed routes.

TypeScript baseUrl Is Ignored

๐Ÿšฆ Impact Level: Minimal

What Changed

With compatibilityVersion: 5, Nuxt removes compilerOptions.baseUrl from its generated TypeScript configurations. Relative Nuxt and Nitro aliases are resolved from Nuxt's build directory instead of a custom baseUrl.

Reasons for Change

TypeScript 6 deprecates baseUrl, and it is no longer required when using paths. Removing the option also keeps Nuxt's generated aliases anchored consistently to the generated configuration that contains them.

Migration Steps

Remove baseUrl from your Nuxt TypeScript configuration. If you used it to anchor a relative Nuxt or Nitro alias, make that alias absolute instead:

nuxt.config.ts
+ import { fileURLToPath } from 'node:url'
+
  export default defineNuxtConfig({
    alias: {
-     images: './assets/images',
+     images: fileURLToPath(new URL('./assets/images', import.meta.url)),
    },
-   typescript: {
-     tsConfig: {
-       compilerOptions: {
-         baseUrl: '..',
-       },
-     },
-   },
  })

Nitro v2 Compatibility

The Nitro v3 changes listed above cover a lot of server code. You don't have to work through all of them before Nuxt 5 will build. Nuxt 5 includes a compatibility layer that runs older server code as it is, so you can upgrade first and migrate afterwards.

Module Server Code

Modules are covered automatically. If a module's server files import from h3, nitropack, #internal/nitro or #imports, Nuxt gives that module what its code expects: the h3 v1 helpers, the old nitropack paths mapped to Nitro v3, useRuntimeConfig(event), and the v1 event properties.

The build tells you which modules this happened to:

WARN [NUXT_B9003] Nitro v2 compatibility was applied to server code from 1 module, because of what it imports:
  - some-module (imports `h3`, `nitropack/runtime`)

You don't need to do anything about this notice. It's aimed at the module's author, and it's a sign that a later release of that module will no longer need the layer.

If the module is yours, learn how to write server code that runs on either Nitro version.

Your Own server/ Directory

Your own server code is not covered by default, because the errors you get are what show you where the work is. If you want to ship the upgrade before doing that work, turn on nitroLegacy:

nuxt.config.ts
export default defineNuxtConfig({
  nitroLegacy: true,
})

With it on, import { defineEventHandler, getQuery } from 'h3', useRuntimeConfig(event), defineNitroPlugin, cachedEventHandler, useEvent and handlers registered without a route all keep working.

Each part is a separate switch, so you can migrate one at a time:

nuxt.config.ts
export default defineNuxtConfig({
  nitroLegacy: {
    h3: true, // `h3` resolves to the h3 v1 helpers
    specifiers: true, // `nitropack`, `nitropack/runtime`, `#internal/nitro`
    runtimeConfig: true, // the `useRuntimeConfig(event)` signature
    imports: false, // the Nitro v2 auto-import names
    config: false, // Nitro v2 config shapes and removed-option warnings
  },
})

The beforeResponse and afterResponse hooks need no switch. Whenever the layer is active, for your code or for a module's, Nuxt emits them from the Nitro v3 response hook and reports any case it can't handle.

What the Layer Doesn't Cover

  • Undeclared dependencies. nitropack v2 put its own dependencies into the server bundle, so a file could import something like lru-cache without depending on it and still work. Add the package to your own dependencies, or report it to the module that needs it.
  • Types. If you augment nitropack/types, update it to the new module paths. See Route Rule Types Move to h3/rules.
  • The event object. With the default builder, event is h3 v2's event. The layer adds back some things h3 v2 dropped, such as event.context.nitro, event.context._nitro.routeRules and a Node-shaped event.node on runtimes that don't have one.

Migration Steps

  1. Upgrade, build, and read the notices. NUXT_B9003 is about modules, so it's their authors' work rather than yours. Errors in your own server/ directory are yours.
  2. Move your server imports to nuxt/server, which is available from Nuxt 4.6, and move anything it doesn't cover to nitro/h3 or nitro/*. Leave nitroLegacy off while you do this.
  3. Turn on nitroLegacy only if you need to ship before you've finished. Then set one switch to false at a time and fix what breaks, until you can remove the option.
  4. If a module is still named by NUXT_B9003, check for a newer release, and open an issue if there isn't one.