Nuxt 4.1 To 4.6
Each item names its tier, the detection, and the change. The Skill defines the tiers.
Items that compatibility version 5 controls live in compatibility version 5.
Run every rg from the app root. Add layer directories when the site has them.
Upgrade
Tier 0.
- Node floor. Nuxt 4.6 requires Node
^22.22.3 || ^24.15.0 || >=26.0.0.- Detect:
rg -n 'node-version|"node":|FROM node' .github package.json Dockerfile .nvmrc .node-version - Change
engines,.nvmrc,.node-version, and CInode-version. Prefer24. - A self-hosted runner uses the host's Node unless CI installs one. Check each runner that builds the site.
- Detect:
- Nuxt version. Raise
nuxtto the latest 4.x release where it is declared: the catalog inpnpm-workspace.yaml, orpackage.json. Then runpnpm dedupe. With another package manager, runnuxt upgrade --dedupe. - Every other dependency. Take the upgrade as the moment to move every dependency to its latest release, majors included. Follow the dependency updates Skill's rules for finding versions and repairing majors.
- List them with
pnpm outdated -r --include-workspace-root --format json. Update catalog entries at their source, and keep each entry's caret or exact pin. - Hold TypeScript on 6, as that Skill requires.
- Hold
h3on 1. Nuxt 4 runs nitropack v2, which needs h3 1; h3 2 belongs to Nitro v3 and Nuxt 5. - Expect majors to break code. On harlanzw.com, VueUse 15 replaced the
useNowintervaloption withscheduler: cb => useIntervalFn(cb, ms), and@antfu/eslint-config9.5 began sortingpnpm-workspace.yamland requiringminimumReleaseAgeExcludePrune: true. Runeslint --fix pnpm-workspace.yamlfor the second. - A
wranglerbump changes the generatedworker-configuration.d.ts. Regenerate it with the repository'swrangler typesscript, or CI's types check fails.
- List them with
- Overrides. Remove a
viteoverride that pins Vite below 8, and anyrolldown-viteoverride. Nuxt 4.5 ships Vite 8 on Rolldown. - Supply-chain policy. With
trustPolicy: no-downgrade, pnpm rejects the cssnano 9 family that@nuxt/vite-builder4.6.0 installs. Expect about 30 packages. Repeat these steps until the install passes:- Run
pnpm install > install.log 2>&1 || rg -o 'trust downgrade for "([^"]+)"' -r '$1' install.log - Check the named package:
npm view <name>@<version> dist.attestations.provenance.predicateType repository.url - If the provenance is SLSA and the repository is the package's own, add the exact
<name>@<version>totrustPolicyExclude. Group the entries under one comment that names the reason. - If the provenance is missing, stop and report the package.
With a release age policy, pnpm adds the new Nuxt packages to
minimumReleaseAgeExcludeitself. Keep those entries; they follow the site's existing pattern.
- Run
- Modules. Build once. Upgrade each module that fails on Vite 8, unhead v3, or vue-router 5.
4.1
- T1:
entryImportMapis on by default. Detect:rg -n entryImportMap nuxt.config.ts. Change: deleteentryImportMap: true. Keepfalseonly with a reason. - T1:
installModuleis deprecated in local modules. Detect:rg -n 'installModule\(' modules layers. Change: declaremoduleDependenciesindefineNuxtModule. - T1:
nuxt.options._layersis private. Detect:rg -n '_layers' modules layers. Change: usegetLayerDirectories(nuxt)from@nuxt/kit. - T1:
.nuxtrctracks module versions foronInstallandonUpgrade. Detect:rg -n nuxtrc .gitignore. Change: commit.nuxtrc.
4.2
- T2:
useAsyncDatahandlers receive an abort signal. Detect:rg -n -A4 'useAsyncData\(' app layers, then find handlers that call$fetch. Change: write(_nuxtApp, { signal }) => $fetch(url, { signal }). Thenrefresh,clear, anddedupe: 'cancel'stop the request. - T2:
experimental.extractAsyncDataHandlersmoves handlers into lazy chunks. A prerendered page then ships none of them. Detect: most routes prerender, throughnitro.static,prerenderroutes, orprerender: trueroute rules. Change: build with and without it. Compare the JavaScript a prerendered page loads up front: the files its HTML names through module scripts andmodulepreloadlinks. Keep it only when that number drops. The total in.output/public/_nuxtgrows either way, because handlers move into extra chunks. On harlanzw.com, whose handlers only query content, it raised a page from 14 files and 882,848 bytes to 21 files and 884,558 bytes, so the site keeps it off. - T3:
experimental.typescriptPluginadds editor features and installs@dxup/nuxt. Follow-up only.
4.3
- T1:
statusCodeandstatusMessageare deprecated. Nuxt 5 removes them. See status renames. - T0: the server tsconfig enables
noUncheckedIndexedAccess. Detect: typecheck. Change: fix each error. Do not turn the option off. - T2:
#serveralias. Detect:rg -n "from '(\.\./){2,}" server. Change: if the target is insideserver/, import it from#server/.... - T2:
appLayoutroute rule. Detect:rg -n 'layout:' app/pages layers, then find one layout repeated under one path prefix. Also find middleware that callssetPageLayoutby path. Change: setrouteRules['/prefix/**'] = { appLayout: 'name' }. Delete the repeatedlayoutkeys. - T2: route groups in
to.meta.groups. Detect: pages in a(group)/folder, and middleware that checks the same pages by path prefix. Change: checkto.meta.groups?.includes('group'). - T2: disable a layer's module with
<configKey>: false. Detect: workarounds that turn off a module a layer installs. Change: set that module's config key tofalse.
Status renames
Rename only Nuxt and h3 error shapes. FetchError.statusCode from ofetch and Response.status are different objects.
h3 v1's createError reads status and statusText too, so server code can change before it moves to nuxt/server.
createErrorandshowErrorarguments. Detect:rg -nU '(createError|showError)\(\{[^}]*status(Code|Message)' app server layers shared. Change:statusandstatusText.error.vueanduseError()reads. Detect:rg -n 'error(\.value)?\??\.status(Code|Message)' app layers. Change:error.statusanderror.statusText.- Other
NuxtErrorreads in plugins and middleware. Detect:rg -n '\.statusCode\b' app layers, then read each hit. Change: only reads of aNuxtError. - Redirect route rules: leave them. nitropack v2 reads only
redirect.statusCode, so a renamedstatussilently drops the 301. See compatibility version 5.
4.4
- T2:
createUseFetchandcreateUseAsyncData. A hand-written wrapper loses automatic key injection, so two calls can share a key. Detect:rg -n 'return use(Lazy)?(Fetch|AsyncData)\(' app/composables layersandrg -n '\$fetch\.create\(' app. Change:export const useApiFetch = createUseFetch({ baseURL })inapp/composables/. Nuxt scans that directory to inject keys, so the factory must live there. Delete the wrapper. - T1: vue-router 5 replaces
unplugin-vue-router. Detect:rg -n unplugin-vue-router package.json app nuxt.config.ts. Change: remove the dependency. Replace eachunplugin-vue-routerimport with itsvue-router5 equivalent, then typecheck. - T2: typed layout props.
Detect:
rg -n 'provide\(|inject\(' app/layouts, oruseStatethat pages set for a layout. Change: declaredefinePropsin the layout. Pass props withdefinePageMeta({ layout: { name, props } }), or withsetPageLayout(name, props)in middleware. - T2:
useAnnouncerand<NuxtAnnouncer>. Detect:rg -n aria-live app layersin hand-written live regions. Change: add<NuxtAnnouncer />toapp.vue. CalluseAnnouncer().polite()or.assertive(). Keep<NuxtRouteAnnouncer />. - T2:
useCookie(name, { refresh: true })extends the expiry on every write. Detect: code that rewrites a cookie only to extend its expiry. Change: addrefresh: true. - T1:
normalizeComponentNamesis on by default. Detect:rg -n normalizeComponentNames nuxt.config.ts. Change: deletenormalizeComponentNames: true. - T3: view transition types, and
nuxt build --profile. Use the profiler when a build is slow.
4.5
- T1: Vite 8 renames esbuild and Rollup options.
Detect:
rg -n 'esbuild|rollupOptions' nuxt.config.ts modules layers. Change:vite.esbuildtovite.oxc.optimizeDeps.esbuildOptionstooptimizeDeps.rolldownOptions.build.rollupOptionstobuild.rolldownOptions. Read the Vite 8 migration guide for CommonJS interop. - T1: unhead v3 drops promise input and narrows
useHeadtypes. Detect:rg -n -A3 'use(Head|SeoMeta)\(' app layers, then findawait, a promise, or an async function in values. Change: resolve values before the call, or pass a computed. Fix each new type error. - T2:
enabledoption foruseFetchanduseAsyncData. Detect:immediate: falsewith awatchthat callsexecute, or a handler that returnsnullunder a condition. Change: passenabled: () => condition. Delete the watch. - T1:
<NuxtLink custom>no longer prefetches by itself. Detect:rg -n '<NuxtLink[^>]*\bcustom\b' app layers. Change: readprefetchandshouldPrefetchfrom the slot. CallshouldPrefetch('interaction') && prefetch()onpointerenterandfocus. - T2:
useLayout()returns the resolved layout. Detect:rg -n 'meta\.layout' app layers. Change: useuseLayout(). - T2:
import.meta.envNameholds the--envNamevalue. Detect: runtime config or environment reads that only carry the environment name. Change: useimport.meta.envName. - T3:
experimental.ssrStreaming. Follow-up. Test status codes, headers, and cookies that code sets after render starts. - T3:
experimental.prefetchPreloadTags. Follow-up. Measure navigation before and after. - T3:
tracingChannel. Follow-up when the site adds OpenTelemetry.
4.6
- T0:
nuxt/serverand explicit server imports. Read server imports. - T1: top-level
prerenderreplacesnitro.prerender. Detect:rg -n -B2 'prerender:' nuxt.config.ts, insidenitro. Change: move the block to top-levelprerender. Movenitro.routeRulesto top-levelrouteRulestoo. - T1:
typescript.tsConfigis a shared baseline.appTsConfigandserverTsConfigoverride per context. Detect:rg -n tsConfig nuxt.config.ts. Change: move server-only options totypescript.serverTsConfig. - T2:
app/types/andserver/types/join the right tsconfig. Detect: ambient.d.tsfiles wired through manualincludeentries. Change: move them intoapp/types/orserver/types/. Delete the manual include. - T2:
experimental.strictRouteTypesrejects$fetchcalls to paths that do not exist. Change: enable it after typed$fetchpasses. If dynamic paths make it noisy, skip it with a reason. - T2:
experimental.early404answers unknown paths before the Vue app starts. Detect:rg -n 'addRoute\(' app layers, a root catch-all page, or middleware that redirects unknown paths. Change: if none exist, enable it. Thencurlan unknown path for a 404, and each route type for its normal status. - T2:
experimental.prerenderErrorPageswrites a real404.html. Detect: a static or mostly prerendered deploy. Change: enable it. Check that.output/public/404.htmlholds rendered HTML. - T2:
experimental.stripNeverHydratedDatakeeps data out of the payload. Detect:rg -n hydrate-never app layers. Change: enable it when the site hashydrate-nevercomponents. For one call, passserialize: false. - T2: addons for
createUseFetchwithdefineUseFetchAddon. Detect: refresh on focus, polling, or retry logic repeated across fetch calls. Change: write one addon. Pass it inaddons. - T2:
<NuxtLink>to apublic/file needs noexternal. Detect:rg -n '<NuxtLink[^>]*external' app layers, then keep only links to files inpublic/. Change: deleteexternal. - T2: new dev warnings. Nuxt warns about a payload over 100 kB, a
noScriptsroute that needs JavaScript, and apublic/file that shadows a route. Detect: runnuxt devand open the main routes. Change: fix each warning, or record why it stays. - T3: Vue Vapor,
@nuxt/vite-server, andappSecretsessions. Out of scope.