Skip to content
TutorialNuxt

Upgrading a Nuxt 3 app to Nuxt 4, step by step

Nuxt 4.0 has been out for a week. This is the upgrade on a small example app, one step at a time, with the command for each step and the diff you should expect to see when it finishes.

vuejs.school team22 Jul 20257 min readNuxt 4.0

Upgrading from Nuxt 3 to Nuxt 4 comes down to a version bump, a new folder layout and some changed defaults, mostly in data fetching and TypeScript; the upgrade guide documents each one. What needs thought is checking your modules and finding the places where your code relied on the old behaviour.

The example app is small and ordinary: a dozen pages, a components/ folder, two composables, a server/api/ folder with four handlers, Pinia, a Vitest suite with a handful of component tests, and nuxt typecheck in CI. It runs on Nuxt 3.17.

Before you start: Node, tests and modules

Three checks come first:

  1. Node. nuxt@4.0.0 declares "node": "^20.19.0 || >=22.12.0" in its engines. If node --version prints a version outside that range, upgrade Node before anything else.
  2. A green suite. Run npx vitest --run and npx nuxi typecheck on Nuxt 3, so that every failure after this point belongs to the upgrade.
  3. A branch for the upgrade.

Then the check that decides whether you continue today. List what you depend on:

npx nuxi info

The output names your Nuxt version and every module in nuxt.config.ts. For each module, open its changelog or releases page and look for a version that states Nuxt 4 support. Write the target version next to each one. If every module has one, continue. If one does not, skip to the last section before you run anything, because the answer there changes which steps you do.

Step 1: upgrade Nuxt and your modules

The Nuxt 4 announcement recommends one command:

npx nuxt upgrade --dedupe

This updates nuxt to 4.0.x, refreshes the packages it depends on, and deduplicates the lockfile so that you do not end up with two copies of an internal package. The diff is package.json with "nuxt": "^4.0.0" and a large lockfile change; nothing in your source moves. Now update the modules you listed to their Nuxt 4 versions in the same commit, with your package manager's normal install command.

Start the dev server. Nuxt detects that pages/ is still at the project root and keeps the old layout, so the app should still load; steps 3 to 6 fix the changed defaults. If it does not start, look at the modules first: fix or pin the one that fails before you continue.

Step 2: run the codemods and move folders into app/

The migration recipe automates the mechanical parts:

npx codemod@latest nuxt/4/migration-recipe

It runs six codemods in sequence and lets you deselect any of them:

  • file-structure moves your app folders and files into app/.
  • default-data-error-value rewrites === null checks on data and error to === undefined.
  • deprecated-dedupe-value replaces boolean dedupe values.
  • shallow-function-reactivity adds { deep: true } to every useFetch and useAsyncData call, which keeps Nuxt 3's deep reactivity everywhere.
  • absolute-watch-path and template-compilation-changes only matter if you write modules.

Deselect shallow-function-reactivity and accept the rest: step 3 finds the few calls that edit fetched data in place, and the others keep the cheaper default.

If you would rather see the move happen, do it by hand; it is one command:

mkdir app
git mv app.vue error.vue app.config.ts pages components composables layouts middleware plugins stores utils assets app/

List only the files and folders you actually have, and leave server/, public/, shared/, modules/, layers/, nuxt.config.ts and tsconfig.json where they are.

Pinia needs stores/ moved as well: @pinia/nuxt auto-imports stores from stores/ inside Nuxt's source folder (srcDir), which is now app/. The command above includes it; if you used the codemod, check that it moved too.

The expected diff is a list of renames with no content changes, because ~/ and @/ now resolve to app/: ~/components/SiteHeader.vue is still right. The exception is a folder that stays in the root: ~/types/note now means app/types/note, so move that folder too or import it as ~~/types/note (~~ is the project root). Restart the dev server, since directory detection happens at start-up, and the app should load again.

Step 3: fix data fetching for the new defaults

This step is a read, not a search-and-replace. Open every useFetch and useAsyncData call and check it against five changes.

The first needs the most care: fetched data is a shallowRef. Here is a page that appends to a list after a mutation, and on Nuxt 4 the list stops updating:

app/pages/notes.vueBreaks
<script setup lang="ts">
type Note = { id: number; text: string }
const { data: notes } = await useFetch<Note[]>('/api/notes')

async function add(text: string) {
  const note = await $fetch<Note>('/api/notes', { method: 'POST', body: { text } })
  notes.value?.push(note)
}
</script>

push changes the array in place, and a shallow ref only reacts to notes.value being replaced. Either replace the value or ask for deep reactivity on that one call:

app/pages/notes.vueWorks
<script setup lang="ts">
type Note = { id: number; text: string }
const { data: notes } = await useFetch<Note[]>('/api/notes')

async function add(text: string) {
  const note = await $fetch<Note>('/api/notes', { method: 'POST', body: { text } })
  notes.value = [...(notes.value ?? []), note]
}
</script>

Replacing is the better habit, because it keeps the cheaper default. Reserve { deep: true } for a page that edits a big nested object field by field.

Then check the other four:

  • undefined, not null. data and error start as undefined, so v-if="data === null" never matches.
  • String dedupe values. refresh({ dedupe: true }) is now refresh({ dedupe: 'cancel' }), and false is 'defer'.
  • pending only during a request. With immediate: false, pending stays false until the first call, so a spinner bound to it no longer shows before then. Read status instead ('idle' before the first call).
  • One key, one set of refs. Calls with the same key now share their data, error and status refs, so their deep, transform, pick, getCachedData and default options must match. Search for duplicate string keys and make the options agree, or the keys different.

Step 4: fix the new type errors

Run the type check:

npx nuxi typecheck

Look for two kinds of new error.

The first comes from noUncheckedIndexedAccess, which Nuxt now turns on. Reading an array element or a record key adds undefined to its type: with notes: Note[], notes[0].text is an error until you write notes[0]?.text or check the element first, and route.params.id becomes string | string[] | undefined. A small app has a handful of these. If you have hundreds, set typescript.tsConfig.compilerOptions.noUncheckedIndexedAccess to false in nuxt.config.ts and come back to it.

The second comes from what the generated configs include. Nuxt now writes tsconfig.app.json, tsconfig.server.json, tsconfig.shared.json and tsconfig.node.json into .nuxt/, next to the .nuxt/tsconfig.json your root config extends. In Nuxt 3 that config covered every file in the project; after the folder move, it and the new ones cover only the folders of the new layout, plus .d.ts files directly in the root. A declare module file in a root types/ folder stops applying, and the types it added turn into errors. Move app declarations into app/, server ones into server/, and shared ones into shared/. The root tsconfig.json can keep extends: "./.nuxt/tsconfig.json"; switching to project references is optional and can wait.

Step 5: update the tests

Update @nuxt/test-utils to 3.19.2 or later, which was published on 1 July and requires Vitest 3.2. Then run the suite and look for two failures:

  • Tests that mock useFetch and return data: ref(null) should return undefined, to match what the component now sees.
  • Tests that find a component by name must use the normalised name. A component at app/components/forms/TextInput.vue is now FormsTextInput, so wrapper.findComponent({ name: 'TextInput' }) finds nothing until you rename it.

mountSuspended and registerEndpoint are unchanged.

Step 6: fix the smaller breaking changes

The guide lists smaller changes too. Grep for these and fix what you find:

  • hid and vmid in useHead calls: rename them to key, or delete them on meta tags, which do not need one. children becomes innerHTML, and body: true becomes tagPosition: 'bodyClose'. unhead: { legacy: true } maps the old names while you fix them.
  • route.meta.name and route.meta.path: read route.name and route.path.
  • JSON.parse(error.data) in error.vue: error.data is already parsed and the call now throws.
  • generate.routes and generate.exclude in nuxt.config.ts: move to nitro.prerender.routes and nitro.prerender.ignore.
  • CSS that targets the SPA loading template inside #__nuxt: it now renders next to it.
  • window.__NUXT__ in any script: read useNuxtApp().payload.

Run the production build, npx nuxi build, and open the result once. Only component CSS is inlined now and global CSS is linked, which is correct but visible in the HTML if you were inspecting it.

When a module is not ready for Nuxt 4

Sometimes a module has no Nuxt 4 release. The announcement expects a few modules to need updates, and most breaking changes come with an option that restores the old behaviour. That leaves three ways forward, in order of preference.

First, try the upgrade anyway, in the branch, and watch what the module trips on. Then turn on only the option it needs:

  • experimental.normalizeComponentNames: false if it looks up components by the old names.
  • unhead: { legacy: true } if it sets hid on meta tags.
  • experimental.granularCachedData: false and experimental.purgeCachedData: false if it relied on the old data layer.
  • srcDir: '.' with dir: { app: 'app' } if it reads files from the old locations.

Note the option in the config with a comment naming the module, and remove it when the module updates.

If the module still fails, stay on Nuxt 3 and open an issue on the module with the error. Nuxt 3 receives maintenance updates (bug fixes and features backported from Nuxt 4) until the end of January 2026, so there is time to wait. Part of the work also runs on Nuxt 3: replacing in-place edits of fetched data, guarding index access, and writing empty checks as data.value == null, which matches both Nuxt 3's null and Nuxt 4's undefined.

If the module is abandoned, this is the moment to replace it, before the upgrade rather than during it, so that the replacement is tested on the version you already run.

Check it stuck3 questions from the Modules, plugins and layers quiz.
Practice now
Was this helpful?

Keep reading