docs/1.getting-started/18.upgrade.md
To upgrade Nuxt to the latest release, use the nuxt upgrade command.
::code-group{sync="pm"}
npx nuxt upgrade
yarn nuxt upgrade
pnpm nuxt upgrade
bun x nuxt upgrade
deno x nuxt upgrade
::
To use the latest Nuxt build and test features before their release, read about the nightly release channel guide.
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+.
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:
<KeepAlive> behaviorclearNuxtState resets to defaults: clearNuxtState will reset state to its initial value instead of setting it to undefinedcallHook: callHook may return void instead of always returning a Promise<div> as SSR placeholders, fixing a scoped styles hydration issue::note
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.
š¦ Impact Level: Medium
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.
::tip
You can test this feature early by setting future.compatibilityVersion: 5 (see Testing Nuxt 5) or by enabling it explicitly with experimental.viteEnvironmentApi: true.
::
Key changes:
Deprecated environment-specific extendViteConfig(): The server and client options in extendViteConfig() are deprecated and will show warnings when used.
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.
Shared configuration: The vite:extendConfig and vite:configResolved hooks now work with a shared configuration rather than separate client/server configs.
The Vite Environment API provides:
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'
},
}))
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'
},
}))
::read-more{to="https://vite.dev/guide/api-environment" target="_blank"} Learn more about Vite's Environment API ::
callHookš¦ Impact Level: Minimal
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.
::tip This affects both build-time Nuxt hooks (used by Nuxt modules) and runtime Nuxt hooks (which you might use in your application code). ::
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.
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) { ... }
::tip
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:
export default defineNuxtConfig({
experimental: {
asyncCallHook: true,
},
})
š¦ Impact Level: Minimal
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.
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.
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>
::tip
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:
export default defineNuxtConfig({
experimental: {
clientNodePlaceholder: false,
},
})
Nuxt 4 includes significant improvements and changes. This guide will help you migrate your existing Nuxt 3 application to Nuxt 4.
First, upgrade to Nuxt 4:
::code-group{sync="pm"}
npm install nuxt@^4.0.0
yarn add nuxt@^4.0.0
pnpm add nuxt@^4.0.0
bun add nuxt@^4.0.0
deno add npm:nuxt@^4.0.0
::
After upgrading, most Nuxt 4 behaviors are now the default. However, some features can still be configured if you need to maintain backward compatibility during your migration.
The following sections detail the key changes and migrations needed when upgrading to Nuxt 4.
Breaking or significant changes are documented below along with migration steps and available configuration options.
To facilitate the upgrade process, we have collaborated with the Codemod team to automate many migration steps with some open-source codemods.
::note
If you encounter any issues, please report them to the Codemod team with npx codemod feedback š
::
For a complete list of Nuxt 4 codemods, detailed information on each, their source, and various ways to run them, visit the Codemod Registry.
You can run all the codemods mentioned in this guide using the following codemod recipe:
::code-group
# Using pinned version due to https://github.com/codemod/codemod/issues/1710
npx [email protected] nuxt/4/migration-recipe
# Using pinned version due to https://github.com/codemod/codemod/issues/1710
yarn dlx [email protected] nuxt/4/migration-recipe
# Using pinned version due to https://github.com/codemod/codemod/issues/1710
pnpm dlx [email protected] nuxt/4/migration-recipe
# Using pinned version due to https://github.com/codemod/codemod/issues/1710
bun x [email protected] nuxt/4/migration-recipe
# Using pinned version due to https://github.com/codemod/codemod/issues/1710
deno x [email protected] nuxt/4/migration-recipe
::
This command will execute all codemods in sequence, with the option to deselect any that you do not wish to run. Each codemod is also listed below alongside its respective change and can be executed independently.
š¦ Impact Level: Significant
Nuxt now defaults to a new directory structure, with backwards compatibility (so if Nuxt detects you are using the old structure, such as with a top-level app/pages/ directory, this new structure will not apply).
š See full RFC
srcDir is app/ by default, and most things are resolved from there.serverDir now defaults to <rootDir>/server rather than <srcDir>/serverlayers/, modules/ and public/ are resolved relative to <rootDir> by defaultcontent/ is resolved relative to <rootDir>dir.app is added, which is the directory we look for router.options.ts and spa-loading-template.html - this defaults to <srcDir>/shared/ directory is available for code shared between the Vue app and the Nitro server, with auto-imports for shared/utils/ and shared/types/.output/
.nuxt/
app/
assets/
components/
composables/
layouts/
middleware/
pages/
plugins/
utils/
app.config.ts
app.vue
router.options.ts
content/
layers/
modules/
node_modules/
public/
shared/
types/
utils/
server/
api/
middleware/
plugins/
routes/
utils/
nuxt.config.ts
::note
With this new structure, the ~ alias now points to the app/ directory by default (your srcDir). This means ~/components resolves to app/components/, ~/pages to app/pages/, etc.
::
š For more details, see the PR implementing this change.
.git/ and node_modules/ folders being scanned/included by FS watchers which can significantly delay startup on non-Mac OSes.server/ and the rest of your app are running in two entirely different contexts with different global imports available, and making sure server/ isn't inside the same folder as the rest of your app is a big first step to ensuring you get good auto-completes in your IDE.:video-accordion{title="Watch a video from Vue School on the new directory structure" videoId="1031028378" platform="vimeo"}
app/.assets/, components/, composables/, app/layouts/, app/middleware/, app/pages/, app/plugins/ and utils/ folders under it, as well as app.vue, error.vue, app.config.ts. If you have an app/router-options.ts or app/spa-loading-template.html, these paths remain the same.nuxt.config.ts, content/, layers/, modules/, public/, shared/ and server/ folders remain outside the app/ folder, in the root of your project.tailwindcss or eslint configuration (if required - @nuxtjs/tailwindcss should automatically configure tailwindcss correctly).::tip
You can automate this migration by running npx codemod@latest nuxt/4/file-structure
::
However, migration is not required. If you wish to keep your current folder structure, Nuxt should auto-detect it. (If it does not, please raise an issue.) The one exception is that if you already have a custom srcDir. In this case, you should be aware that your modules/, public/, shared/ and server/ folders will be resolved from your rootDir rather than from your custom srcDir. You can override this by configuring dir.modules, dir.public and serverDir if you need to.
You can also force a v3 folder structure with the following configuration:
export default defineNuxtConfig({
// This reverts the new srcDir default from `app` back to your root directory
srcDir: '.',
// This specifies the directory prefix for `router.options.ts` and `spa-loading-template.html`
dir: {
app: 'app',
},
})
š¦ Impact Level: Moderate
Nuxt's data fetching system (useAsyncData and useFetch) has been significantly reorganized for better performance and consistency:
Shared refs for the same key: All calls to useAsyncData or useFetch with the same key now share the same data, error and status refs. This means that it is important that all calls with an explicit key must not have conflicting deep, transform, pick, getCachedData or default options.
More control over getCachedData: The getCachedData function is now called every time data is fetched, even if this is caused by a watcher or calling refreshNuxtData. (Previously, new data was always fetched and this function was not called in these cases.) To allow more control over when to use cached data and when to refetch, the function now receives a context object with the cause of the request.
Reactive key support: You can now use computed refs, plain refs or getter functions as keys, which enables automatic data refetching (and stores data separately).
Data cleanup: When the last component using data fetched with useAsyncData is unmounted, Nuxt will remove that data to avoid ever-growing memory usage.
These changes have been made to improve memory usage and increase consistency with loading states across calls of useAsyncData.
Check for inconsistent options: Review any components using the same key with different options or fetch functions.
// This will now trigger a warning
const { data: users1 } = useAsyncData('users', () => $fetch('/api/users'), { deep: false })
const { data: users2 } = useAsyncData('users', () => $fetch('/api/users'), { deep: true })
It may be beneficial to extract any calls to useAsyncData that share an explicit key (and have custom options) into their own composable:
export function useUserData (userId: string) {
return useAsyncData(
`user-${userId}`,
() => fetchUser(userId),
{
deep: true,
transform: user => ({ ...user, lastAccessed: new Date() }),
},
)
}
Update getCachedData implementations:
useAsyncData('key', fetchFunction, {
- getCachedData: (key, nuxtApp) => {
- return cachedData[key]
- }
+ getCachedData: (key, nuxtApp, ctx) => {
+ // ctx.cause - can be 'initial' | 'refresh:hook' | 'refresh:manual' | 'watch'
+
+ // Example: Don't use cache on manual refresh
+ if (ctx.cause === 'refresh:manual') return undefined
+
+ return cachedData[key]
+ }
})
Alternatively, for now, you can disable this behaviour with:
export default defineNuxtConfig({
experimental: {
granularCachedData: false,
purgeCachedData: false,
},
})
š¦ Impact Level: Minimal
The order in which modules are loaded when using Nuxt layers has been corrected. Previously, modules from the project root were loaded before modules from extended layers, which was the reverse of the expected behavior.
Now modules are loaded in the correct order:
This affects both:
modules array in nuxt.config.tsmodules/ directoryThis change ensures that:
Most projects will not need changes, as this corrects the loading order to match expected behavior.
However, if your project was relying on the previous incorrect order, you may need to:
Example of the new correct order:
// Layer: my-layer/nuxt.config.ts
export default defineNuxtConfig({
modules: ['layer-module-1', 'layer-module-2'],
})
// Project: nuxt.config.ts
export default defineNuxtConfig({
extends: ['./my-layer'],
modules: ['project-module-1', 'project-module-2'],
})
// Loading order (corrected):
// 1. layer-module-1
// 2. layer-module-2
// 3. project-module-1 (can override layer modules)
// 4. project-module-2 (can override layer modules)
If you encounter issues with module order dependencies due to needing to register a hook, consider using the modules:done hook for modules that need to call a hook. This is run after all other modules have been loaded, which means it is safe to use.
š See PR #31507 and issue #25719 for more details.
š¦ Impact Level: Minimal
It's possible to set some route metadata using definePageMeta, such as the name, path, and so on. Previously these were available both on the route and on route metadata (for example, route.name and route.meta.name).
Now, they are only accessible on the route object.
This is a result of enabling experimental.scanPageMeta by default, and is a performance optimization.
The migration should be straightforward:
const route = useRoute()
- console.log(route.meta.name)
+ console.log(route.name)
š¦ Impact Level: Moderate
Vue will now generate component names that match the Nuxt pattern for component naming.
By default, if you haven't set it manually, Vue will assign a component name that matches the filename of the component.
āā components/
āāāā SomeFolder/
āāāāāā MyComponent.vue
In this case, the component name would be MyComponent, as far as Vue is concerned. If you wanted to use <KeepAlive> with it, or identify it in the Vue DevTools, you would need to use this name.
But in order to auto-import it, you would need to use SomeFolderMyComponent.
With this change, these two values will match, and Vue will generate a component name that matches the Nuxt pattern for component naming.
Ensure that you use the updated name in any tests which use findComponent from @vue/test-utils and in any <KeepAlive> which depends on the name of your component.
Alternatively, for now, you can disable this behaviour with:
export default defineNuxtConfig({
experimental: {
normalizeComponentNames: false,
},
})
š¦ Impact Level: Minimal
Unhead, used to generate <head> tags, has been updated to version 2. While mostly compatible it includes several breaking changes
for lower-level APIs.
vmid, hid, children, body.The above changes should have minimal impact on your app.
If you have issues you should verify:
useHead({
meta: [{
name: 'description',
// meta tags don't need a vmid, or a key
- vmid: 'description'
- hid: 'description'
}]
})
import { AliasSortingPlugin, TemplateParamsPlugin } from '@unhead/vue/plugins'
export default defineNuxtPlugin({
setup () {
const unhead = injectHead()
unhead.use(TemplateParamsPlugin)
unhead.use(AliasSortingPlugin)
},
})
While not required it's recommended to update any imports from @unhead/vue to #imports or nuxt/app.
-import { useHead } from '@unhead/vue'
+import { useHead } from '#imports'
If you still have issues you may revert to the v1 behavior by enabling the head.legacy config.
export default defineNuxtConfig({
unhead: {
legacy: true,
},
})
š¦ Impact Level: Minimal
When rendering a client-only page (with ssr: false), we optionally render a loading screen (from ~/app/spa-loading-template.html - note that this has also changed to ~/spa-loading-template.html in Nuxt 4), within the Nuxt app root:
<div id="__nuxt">
<!-- spa loading template -->
</div>
Now, we default to rendering the template alongside the Nuxt app root:
<div id="__nuxt"></div>
<!-- spa loading template -->
This allows the spa loading template to remain in the DOM until the Vue app suspense resolves, preventing a flash of white.
If you were targeting the spa loading template with CSS or document.queryElement you will need to update your selectors. For this purpose you can use the new app.spaLoaderTag and app.spaLoaderAttrs configuration options.
Alternatively, you can revert to the previous behaviour with:
export default defineNuxtConfig({
experimental: {
spaLoadingTemplateLocation: 'within',
},
})
error.dataš¦ Impact Level: Minimal
It was possible to throw an error with a data property, but this was not parsed. Now, it is parsed and made available in the error object. Although a fix, this is technically a breaking change if you were relying on the previous behavior and parsing it manually.
Update your custom error.vue to remove any additional parsing of error.data:
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps({
error: Object as () => NuxtError
})
- const data = JSON.parse(error.data)
+ const data = error.data
</script>
š¦ Impact Level: Moderate
Nuxt will now only inline styles for Vue components, not global CSS.
Previously, Nuxt would inline all CSS, including global styles, and remove <link> elements to separate CSS files. Now, Nuxt will only do this for Vue components (which previously produced separate chunks of CSS). We think this is a better balance of reducing separate network requests (just as before, there will not be separate requests for individual .css files per-page or per-component on the initial load), as well as allowing caching of a single global CSS file and reducing the document download size of the initial request.
This feature is fully configurable and you can revert to the previous behavior by setting inlineStyles: true to inline global CSS as well as per-component CSS.
export default defineNuxtConfig({
features: {
inlineStyles: true,
},
})
š¦ Impact Level: Minimal
We now scan page metadata (defined in definePageMeta) after calling the pages:extend hook rather than before.
This was to allow scanning metadata for pages that users wanted to add in pages:extend. We still offer an opportunity to change or override page metadata in a new pages:resolved hook.
If you want to override page metadata, do that in pages:resolved rather than in pages:extend.
export default defineNuxtConfig({
hooks: {
- 'pages:extend'(pages) {
+ 'pages:resolved'(pages) {
const myPage = pages.find(page => page.path === '/')
myPage.meta ||= {}
myPage.meta.layout = 'overridden-layout'
}
}
})
Alternatively, you can revert to the previous behaviour with:
export default defineNuxtConfig({
experimental: {
scanPageMeta: true,
},
})
š¦ Impact Level: Medium
We enabled a previously experimental feature to share data from useAsyncData and useFetch calls, across different pages. See original PR.
This feature automatically shares payload data between pages that are prerendered. This can result in a significant performance improvement when prerendering sites that use useAsyncData or useFetch and fetch the same data in different pages.
For example, if your site requires a useFetch call for every page (for example, to get navigation data for a menu, or site settings from a CMS), this data would only be fetched once when prerendering the first page that uses it, and then cached for use when prerendering other pages.
Make sure that any unique key of your data is always resolvable to the same data. For example, if you are using useAsyncData to fetch data related to a particular page, you should provide a key that uniquely matches that data. (useFetch should do this automatically for you.)
// This would be unsafe in a dynamic page (e.g. `[slug].vue`) because the route slug makes a difference
// to the data fetched, but Nuxt can't know that because it's not reflected in the key.
const route = useRoute()
const { data } = await useAsyncData(async () => {
return await $fetch(`/api/my-page/${route.params.slug}`)
})
// Instead, you should use a key that uniquely identifies the data fetched.
const { data } = await useAsyncData(route.params.slug, async () => {
return await $fetch(`/api/my-page/${route.params.slug}`)
})
Alternatively, you can disable this feature with:
export default defineNuxtConfig({
experimental: {
sharedPrerenderData: false,
},
})
data and error values in useAsyncData and useFetchš¦ Impact Level: Minimal
data and error objects returned from useAsyncData will now default to undefined.
Previously data was initialized to null but reset in clearNuxtData to undefined. error was initialized to null. This change is to bring greater consistency.
If you were checking if data.value or error.value were null, you can update these checks to check for undefined instead.
::tip
You can automate this step by running npx codemod@latest nuxt/4/default-data-error-value
::
boolean values for dedupe option when calling refresh in useAsyncData and useFetchš¦ Impact Level: Minimal
Previously it was possible to pass dedupe: boolean to refresh. These were aliases of cancel (true) and defer (false).
// @errors: 2322
const { refresh } = await useAsyncData(() => Promise.resolve({ message: 'Hello, Nuxt!' }))
async function refreshData () {
await refresh({ dedupe: true })
}
These aliases were removed, for greater clarity.
The issue came up when adding dedupe as an option to useAsyncData, and we removed the boolean values as they ended up being opposites.
refresh({ dedupe: false }) meant do not cancel existing requests in favour of this new one. But passing dedupe: true within the options of useAsyncData means do not make any new requests if there is an existing pending request. (See PR.)
The migration should be straightforward:
const { refresh } = await useAsyncData(async () => ({ message: 'Hello, Nuxt 3!' }))
async function refreshData () {
- await refresh({ dedupe: true })
+ await refresh({ dedupe: 'cancel' })
- await refresh({ dedupe: false })
+ await refresh({ dedupe: 'defer' })
}
::tip
You can automate this step by running npx codemod@latest nuxt/4/deprecated-dedupe-value
::
data in useAsyncData and useFetchš¦ Impact Level: Minimal
If you provide a custom default value for useAsyncData, this will now be used when calling clear or clearNuxtData and it will be reset to its default value rather than simply unset.
Often users set an appropriately empty value, such as an empty array, to avoid the need to check for null/undefined when iterating over it. This should be respected when resetting/clearing the data.
useStateš¦ Impact Level: Minimal
With compatibilityVersion: 5, clearNuxtState will reset state to its initial value (provided by the init function of useState) instead of setting it to undefined. This aligns clearNuxtState behavior with clearNuxtData, which already resets to defaults.
When clearNuxtState sets state to undefined, composables that depend on that state can crash because they expect the state to always have a valid shape (e.g., accessing properties on undefined). Resetting to the init value ensures state always has a usable default.
If you rely on clearNuxtState setting state to undefined, you can explicitly pass { reset: false }:
- clearNuxtState('myKey')
+ clearNuxtState('myKey', { reset: false })
Alternatively, you can revert to the previous behavior with:
export default defineNuxtConfig({
experimental: {
defaults: {
useState: {
resetOnClear: false,
},
},
},
})
You can also opt in to this behavior today without setting compatibilityVersion: 5:
export default defineNuxtConfig({
experimental: {
defaults: {
useState: {
resetOnClear: true,
},
},
},
})
pending value in useAsyncData and useFetchš¦ Impact Level: Medium
The pending object returned from useAsyncData, useFetch, useLazyAsyncData and useLazyFetch is now a computed property that is true only when status is also pending.
Now, when immediate: false is passed, pending will be false until the first request is made. This is a change from the previous behavior, where pending was always true until the first request was made.
This aligns the meaning of pending with the status property, which is also pending when the request is in progress.
If you rely on the pending property, ensure that your logic accounts for the new behavior where pending will only be true when the status is also pending.
<template>
- <div v-if="!pending">
+ <div v-if="status === 'success'">
<p>Data: {{ data }}</p>
</div>
<div v-else>
<p>Loading...</p>
</div>
</template>
<script setup lang="ts">
const { data, pending, execute, status } = await useAsyncData(() => fetch('/api/data'), {
immediate: false
})
onMounted(() => execute())
</script>
Alternatively, you can temporarily revert to the previous behavior with:
export default defineNuxtConfig({
experimental: {
pendingWhenIdle: true,
},
})
useAsyncData and useFetchš¦ Impact Level: Medium
When using reactive keys in useAsyncData or useFetch, Nuxt automatically refetches data when the key changes. When immediate: false is set, useAsyncData will only fetch data when the key changes if the data has already been fetched once.
Previously, useFetch had slightly different behavior. It would always fetch data when the key changed.
Now, useFetch and useAsyncData behave consistently - by only fetch data when the key changes if the data has already been fetched once.
This ensures consistent behavior between useAsyncData and useFetch, and prevents unexpected fetches. If you have set immediate: false, then you must call refresh or execute or data will never be fetched in useFetch or useAsyncData.
This change should generally improve the expected behavior, but if you were expecting changing the key or options of a non-immediate useFetch, you now will need to trigger it manually the first time.
const id = ref('123')
const { data, execute } = await useFetch('/api/test', {
query: { id },
immediate: false
)
+ watch(id, () => execute(), { once: true })
To opt out of this behavior:
// Or globally in your Nuxt config
export default defineNuxtConfig({
experimental: {
alwaysRunFetchOnKeyChange: true,
},
})
useAsyncData and useFetchš¦ Impact Level: Minimal
The data object returned from useAsyncData, useFetch, useLazyAsyncData and useLazyFetch is now a shallowRef rather than a ref.
When new data is fetched, anything depending on data will still be reactive because the entire object is replaced. But if your code changes a property within that data structure, this will not trigger any reactivity in your app.
This brings a significant performance improvement for deeply nested objects and arrays because Vue does not need to watch every single property/array for modification. In most cases, data should also be immutable.
In most cases, no migration steps are required, but if you rely on the reactivity of the data object then you have two options:
- const { data } = useFetch('/api/test')
+ const { data } = useFetch('/api/test', { deep: true })
export default defineNuxtConfig({
experimental: {
defaults: {
useAsyncData: {
deep: true,
},
},
},
})
::tip
If you need to, you can automate this step by running npx codemod@latest nuxt/4/shallow-function-reactivity
::
builder:watchš¦ Impact Level: Minimal
The Nuxt builder:watch hook now emits a path which is absolute rather than relative to your project srcDir.
This allows us to support watching paths which are outside your srcDir, and offers better support for layers and other more complex patterns.
We have already proactively migrated the public Nuxt modules which we are aware use this hook. See issue #25339.
However, if you are a module author using the builder:watch hook and wishing to remain backwards/forwards compatible, you can use the following code to ensure that your code works the same in both Nuxt v3 and Nuxt v4:
+ import { relative, resolve } from 'node:fs'
// ...
nuxt.hook('builder:watch', async (event, path) => {
+ path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path))
// ...
})
::tip
You can automate this step by running npx codemod@latest nuxt/4/absolute-watch-path
::
window.__NUXT__ objectWe are removing the global window.__NUXT__ object after the app finishes hydration.
This opens the way to multi-app patterns (#21635) and enables us to focus on a single way to access Nuxt app data - useNuxtApp().
The data is still available, but can be accessed with useNuxtApp().payload:
- console.log(window.__NUXT__)
+ console.log(useNuxtApp().payload)
š¦ Impact Level: Medium
Child folders in your app/middleware/ folder are also scanned for index files and these are now also registered as middleware in your project.
Nuxt scans a number of folders automatically, including app/middleware/ and app/plugins/.
Child folders in your app/plugins/ folder are scanned for index files and we wanted to make this behavior consistent between scanned directories.
Probably no migration is necessary but if you wish to revert to previous behavior you can add a hook to filter out these middleware:
export default defineNuxtConfig({
hooks: {
'app:resolve' (app) {
app.middleware = app.middleware.filter(mw => !/\/index\.[^/]+$/.test(mw.path))
},
},
})
š¦ Impact Level: Minimal
Previously, Nuxt used lodash/template to compile templates located on the file system using the .ejs file format/syntax.
In addition, we provided some template utilities (serialize, importName, importSources) which could be used for code-generation within these templates, which are now being removed.
In Nuxt v3 we moved to a 'virtual' syntax with a getContents() function which is much more flexible and performant.
In addition, lodash/template has had a succession of security issues. These do not really apply to Nuxt projects because it is being used at build-time, not runtime, and by trusted code. However, they still appear in security audits. Moreover, lodash is a hefty dependency and is unused by most projects.
Finally, providing code serialization functions directly within Nuxt is not ideal. Instead, we maintain projects like unjs/knitwork which can be dependencies of your project, and where security issues can be reported/resolved directly without requiring an upgrade of Nuxt itself.
We have raised PRs to update modules using EJS syntax, but if you need to do this yourself, you have three backwards/forwards-compatible alternatives:
getContents().es-toolkit/compat (a drop-in replacement for lodash template), as a dependency of your project rather than Nuxt:+ import { readFileSync } from 'node:fs'
+ import { template } from 'es-toolkit/compat'
// ...
addTemplate({
fileName: 'appinsights-vue.js'
options: { /* some options */ },
- src: resolver.resolve('./runtime/plugin.ejs'),
+ getContents({ options }) {
+ const contents = readFileSync(resolver.resolve('./runtime/plugin.ejs'), 'utf-8')
+ return template(contents)({ options })
+ },
})
Finally, if you are using the template utilities (serialize, importName, importSources), you can replace them as follows with utilities from knitwork:
import { genDynamicImport, genImport, genSafeVariableName } from 'knitwork'
const serialize = (data: any) => JSON.stringify(data, null, 2).replace(/"\{(.+)\}"(?=,?$)/gm, r => JSON.parse(r).replace(/^\{(.*)\}$/, '$1'))
const importSources = (sources: string | string[], { lazy = false } = {}) => {
return toArray(sources).map((src) => {
if (lazy) {
return `const ${genSafeVariableName(src)} = ${genDynamicImport(src, { comment: `webpackChunkName: ${JSON.stringify(src)}` })}`
}
return genImport(src, genSafeVariableName(src))
}).join('\n')
}
const importName = genSafeVariableName
::tip
You can automate this step by running npx codemod@latest nuxt/4/template-compilation-changes
::
š¦ Impact Level: Minimal
compilerOptions.noUncheckedIndexedAccess is now true instead of false.
This change is a follow up to a prior 3.12 config update where we improved our defaults, mostly adhering to TotalTypeScript's recommendations.
There are two approaches:
Run a typecheck on your app and fix any new errors (recommended).
Override the new default in your nuxt.config.ts:
export default defineNuxtConfig({
typescript: {
tsConfig: {
compilerOptions: {
noUncheckedIndexedAccess: false,
},
},
},
})
š¦ Impact Level: Minimal
Nuxt now generates separate TypeScript configurations for different contexts to provide better type-checking experiences:
New TypeScript configuration files: Nuxt now generates additional TypeScript configurations:
.nuxt/tsconfig.app.json - For your app code (Vue components, composables, etc.).nuxt/tsconfig.server.json - For your server-side code (Nitro/server directory).nuxt/tsconfig.node.json - For your build-time code (modules, nuxt.config.ts, etc.).nuxt/tsconfig.shared.json - For code shared between app and server contexts (like types and non-environment specific utilities).nuxt/tsconfig.json - Legacy configuration for backward compatibilityBackward compatibility: Existing projects that extend .nuxt/tsconfig.json will continue to work as before.
Opt-in project references: New projects or those wanting better type checking can adopt TypeScript's project references feature.
Context-specific type checking: Each context now has appropriate compiler options and includes/excludes for its specific environment.
New typescript.nodeTsConfig option: You can now customize the TypeScript configuration for Node.js build-time code.
This change provides several benefits:
For example, auto-imports are not available in your nuxt.config.ts (but previously this was not flagged by TypeScript). And while IDEs recognized the separate context hinted by tsconfig.json in your server/ directory, this was not reflected in type-checking (requiring a separate step).
No migration is required - existing projects will continue to work as before.
However, to take advantage of improved type checking, you can opt in to the new project references approach:
Update your root tsconfig.json to use project references:
::note
If your tsconfig.json currently has an "extends": "./.nuxt/tsconfig.json" line, remove it before adding the references. Project references and extends are mutually exclusive.
::
{
// Remove "extends": "./.nuxt/tsconfig.json" if present
"files": [],
"references": [
{ "path": "./.nuxt/tsconfig.app.json" },
{ "path": "./.nuxt/tsconfig.server.json" },
{ "path": "./.nuxt/tsconfig.shared.json" },
{ "path": "./.nuxt/tsconfig.node.json" }
]
}
Remove any manual server tsconfig.json files (like server/tsconfig.json) that extended .nuxt/tsconfig.server.json.
Update your type checking scripts to use the build flag for project references:
- "typecheck": "nuxt prepare && vue-tsc --noEmit"
+ "typecheck": "nuxt prepare && vue-tsc -b --noEmit"
Move all type augmentations into their appropriate context:
app/ directory.server/ directory.shared/ directory.::warning
Augmenting types from outside the app/, server/, or shared/ directories will not work with the new project references setup.
::
Configure TypeScript options if needed:
<!-- @case-police-ignore tsConfig -->export default defineNuxtConfig({
typescript: {
// customize tsconfig.app.json
tsConfig: {
// ...
},
// customize tsconfig.shared.json
sharedTsConfig: {
// ...
},
// customize tsconfig.node.json
nodeTsConfig: {
// ...
},
},
nitro: {
typescript: {
// customize tsconfig.server.json
tsConfig: {
// ...
},
},
},
})
Update any CI/build scripts that run TypeScript checking to ensure they use the new project references approach.
The new configuration provides better type safety and IntelliSense for projects that opt in, while maintaining full backward compatibility for existing setups.
š¦ Impact Level: Minimal
Four experimental features are no longer configurable in Nuxt 4:
experimental.treeshakeClientOnly will be true (default since v3.0)experimental.configSchema will be true (default since v3.3)experimental.polyfillVueUseHead will be false (default since v3.4)experimental.respectNoSSRHeader will be false (default since v3.4)vite.devBundler is no longer configurable - it will use vite-node by defaultThese options have been set to their current values for some time and we do not have a reason to believe that they need to remain configurable.
polyfillVueUseHead is implementable in user-land with this plugin
respectNoSSRHeaderis implementable in user-land with server middleware
generate Configurationš¦ Impact Level: Minimal
The top-level generate configuration option is no longer available in Nuxt 4. This includes all of its properties:
generate.exclude - for excluding routes from prerenderinggenerate.routes - for specifying routes to prerenderThe top level generate configuration was a holdover from Nuxt 2. We've supported nitro.prerender for a while now, and it is the preferred way to configure prerendering in Nuxt 3+.
Replace generate configuration with the corresponding nitro.prerender options:
export default defineNuxtConfig({
- generate: {
- exclude: ['/admin', '/private'],
- routes: ['/sitemap.xml', '/robots.txt']
- }
+ nitro: {
+ prerender: {
+ ignore: ['/admin', '/private'],
+ routes: ['/sitemap.xml', '/robots.txt']
+ }
+ }
})
::read-more{to="https://nitro.build/config#prerender"} Read more about Nitro's prerender configuration options. ::
š¦ Impact Level: Minimal
When future.compatibilityVersion is set to 5 (or experimental.normalizePageNames is enabled), page component names match their route names instead of using the filename. For example, pages/foo/index.vue will have the component name foo instead of index.
Previously, Vue assigned component names based on the filename. This meant multiple pages like pages/foo/index.vue and pages/bar/index.vue would both have the component name index. This made <KeepAlive> with include/exclude filters unreliable and required manually adding defineOptions({ name: '...' }) to each page.
If you rely on the current component names (e.g. in <KeepAlive> include/exclude lists), update them to use route names instead of filenames.
<template>
<NuxtPage :keepalive="{
- include: ['index']
+ include: ['foo']
}" />
</template>
To disable this behavior:
export default defineNuxtConfig({
experimental: {
normalizePageNames: false,
},
})
In the table below, there is a quick comparison between 3 versions of Nuxt:
| Feature / Version | Nuxt 2 | Nuxt Bridge | Nuxt 3+ |
|---|---|---|---|
| Vue | 2 | 2 | 3 |
| Stability | š Stable | š Stable | š Stable |
| Performance | š Fast | āļø Faster | š Fastest |
| Nitro Engine | ā | ā | ā |
| ESM support | š Partial | š Better | ā |
| TypeScript | āļø Opt-in | š§ Partial | ā |
| Composition API | ā | š§ Partial | ā |
| Options API | ā | ā | ā |
| Components Auto Import | ā | ā | ā |
<script setup> syntax | ā | š§ Partial | ā |
| Auto Imports | ā | ā | ā |
| webpack | 4 | 4 | 5 |
| Vite | ā ļø Partial | š§ Partial | ā |
| Nuxt CLI | ā Old | ā nuxt | ā nuxt |
| Static sites | ā | ā | ā |
The migration guide provides a step-by-step comparison of Nuxt 2 features to Nuxt 3+ features and guidance to adapt your current application.
::read-more{to="/docs/4.x/migration/overview"} Check out the guide to migrating from Nuxt 2 to Nuxt 3. ::
If you prefer to progressively migrate your Nuxt 2 application to Nuxt 3, you can use Nuxt Bridge. Nuxt Bridge is a compatibility layer that allows you to use Nuxt 3+ features in Nuxt 2 with an opt-in mechanism.
::read-more{to="/docs/4.x/bridge/overview"} Migrate from Nuxt 2 to Nuxt Bridge ::