---
title: "Upgrading a Nuxt 3 app to Nuxt 4, step by step · vuejs.school"
description: "Walk a small Nuxt 3 app through the Nuxt 4 upgrade: check modules, run the upgrade and codemod, move folders, fix data fetching, types and tests."
canonical_url: "https://vuejs.school/community/blog/upgrading-a-nuxt-3-app-to-nuxt-4"
meta:
  "og:description": "Walk a small Nuxt 3 app through the Nuxt 4 upgrade: check modules, run the upgrade and codemod, move folders, fix data fetching, types and tests."
  "og:image": "https://vuejs.school/_og/d/a_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcA%2Cdescription_~V2FsayBhIHNtYWxsIE51eHQgMyBhcHAgdGhyb3VnaCB0aGUgTnV4dCA0IHVwZ3JhZGU6IGNoZWNrIG1vZHVsZXMsIHJ1biB0aGUgdXBncmFkZSBhbmQgY29kZW1vZCwgbW92ZSBmb2xkZXJzLCBmaXggZGF0YSBmZXRjaGluZywgdHlwZXMgYW5kIHRlc3RzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91cGdyYWRpbmctYS1udXh0LTMtYXBwLXRvLW51eHQtNCI%2Cs_kzZPEedc3_-ym0zm.png"
  "og:image:alt": "Upgrading a Nuxt 3 app to Nuxt 4, step by step · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Upgrading a Nuxt 3 app to Nuxt 4, step by step · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcA%2Cdescription_~V2FsayBhIHNtYWxsIE51eHQgMyBhcHAgdGhyb3VnaCB0aGUgTnV4dCA0IHVwZ3JhZGU6IGNoZWNrIG1vZHVsZXMsIHJ1biB0aGUgdXBncmFkZSBhbmQgY29kZW1vZCwgbW92ZSBmb2xkZXJzLCBmaXggZGF0YSBmZXRjaGluZywgdHlwZXMgYW5kIHRlc3RzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91cGdyYWRpbmctYS1udXh0LTMtYXBwLXRvLW51eHQtNCI%2Cs_kzZPEedc3_-ym0zm.png"
  "twitter:image:alt": "Upgrading a Nuxt 3 app to Nuxt 4, step by step · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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](https://nuxt.com/blog/v4) 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.vue**Breaks***

```
<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.vue**Works***

```
<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 stuck**3 questions from the Modules, plugins and layers quiz.

[**Practice now**](https://vuejs.school/learn/nuxt/modules-and-the-nuxt-ecosystem?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**What's new in Nuxt 4.6: nuxt/server, typed $fetch, cookie sessions and Vapor**](https://vuejs.school/community/blog/whats-new-in-nuxt-4-6) [Tutorial · 6 min read**useFetch or $fetch? Picking the right one in Nuxt**](https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/upgrading-a-nuxt-3-app-to-nuxt-4#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-07-22","description":"Walk a small Nuxt 3 app through the Nuxt 4 upgrade: check modules, run the upgrade and codemod, move folders, fix data fetching, types and tests.","name":"Upgrading a Nuxt 3 app to Nuxt 4, step by step","url":"https://vuejs.school/community/blog/upgrading-a-nuxt-3-app-to-nuxt-4","breadcrumb":{"@id":"https://vuejs.school/community/blog/upgrading-a-nuxt-3-app-to-nuxt-4#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/upgrading-a-nuxt-3-app-to-nuxt-4"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/upgrading-a-nuxt-3-app-to-nuxt-4#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-07-22","description":"Walk a small Nuxt 3 app through the Nuxt 4 upgrade: check modules, run the upgrade and codemod, move folders, fix data fetching, types and tests.","headline":"Upgrading a Nuxt 3 app to Nuxt 4, step by step","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcA%2Cdescription_~V2FsayBhIHNtYWxsIE51eHQgMyBhcHAgdGhyb3VnaCB0aGUgTnV4dCA0IHVwZ3JhZGU6IGNoZWNrIG1vZHVsZXMsIHJ1biB0aGUgdXBncmFkZSBhbmQgY29kZW1vZCwgbW92ZSBmb2xkZXJzLCBmaXggZGF0YSBmZXRjaGluZywgdHlwZXMgYW5kIHRlc3RzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91cGdyYWRpbmctYS1udXh0LTMtYXBwLXRvLW51eHQtNCI%2Cs_kzZPEedc3_-ym0zm.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/upgrading-a-nuxt-3-app-to-nuxt-4#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/upgrading-a-nuxt-3-app-to-nuxt-4#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/upgrading-a-nuxt-3-app-to-nuxt-4#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Upgrading a Nuxt 3 app to Nuxt 4, step by step","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcA%2Cdescription_~V2FsayBhIHNtYWxsIE51eHQgMyBhcHAgdGhyb3VnaCB0aGUgTnV4dCA0IHVwZ3JhZGU6IGNoZWNrIG1vZHVsZXMsIHJ1biB0aGUgdXBncmFkZSBhbmQgY29kZW1vZCwgbW92ZSBmb2xkZXJzLCBmaXggZGF0YSBmZXRjaGluZywgdHlwZXMgYW5kIHRlc3RzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91cGdyYWRpbmctYS1udXh0LTMtYXBwLXRvLW51eHQtNCI%2Cs_kzZPEedc3_-ym0zm.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~VXBncmFkaW5nIGEgTnV4dCAzIGFwcCB0byBOdXh0IDQsIHN0ZXAgYnkgc3RlcA%2Cdescription_~V2FsayBhIHNtYWxsIE51eHQgMyBhcHAgdGhyb3VnaCB0aGUgTnV4dCA0IHVwZ3JhZGU6IGNoZWNrIG1vZHVsZXMsIHJ1biB0aGUgdXBncmFkZSBhbmQgY29kZW1vZCwgbW92ZSBmb2xkZXJzLCBmaXggZGF0YSBmZXRjaGluZywgdHlwZXMgYW5kIHRlc3RzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91cGdyYWRpbmctYS1udXh0LTMtYXBwLXRvLW51eHQtNCI%2Cs_kzZPEedc3_-ym0zm.png"}]}
```