Skip to content
Release notesNuxt

Nuxt 4.0 is here: the app/ directory, data fetching changes and the upgrade

Nuxt 4.0.0 landed on 15 July, eight days after its first release candidate. It is a stability release: most of what it changes shipped as options in Nuxt 3 minors, and 4.0 turns them into defaults.

vuejs.school team17 Jul 20256 min readNuxt 4.0

Nuxt 4.0.0 reached npm on 15 July. It moves application code into an app/ folder, changes how useFetch and useAsyncData treat the data they return, and splits type checking into separate TypeScript projects for app, server and shared code.

If you run a Nuxt 3 app, two commands do most of the upgrade. What they cannot check is how your pages use fetched data, and that is where an upgraded app most often behaves differently without an error.

What shipped

The announcement groups the release under four headings. With the details from the upgrade guide, they are:

  • A new project structure: application code moves into app/, with server/, shared/, public/, layers/ and modules/ staying at the root.
  • Smarter data fetching: components that call useAsyncData or useFetch with the same key share one set of refs, data is cleaned up when the last component using it unmounts, keys can be reactive, and data is shallow by default.
  • A better TypeScript setup: Nuxt generates separate TypeScript projects for app, server, shared/ and build-time code, so the editor stops suggesting browser APIs in server code, and server APIs in app code.
  • A faster CLI: the CLI and the Vite dev server talk over an internal socket instead of a network port, file watching uses native fs.watch, and the Node.js compile cache is reused between starts.

The app/ directory: what moves and why

In Nuxt 4, a project with the usual folders looks like this:

my-app/
  app/
    app.vue
    assets/
    components/
    composables/
    layouts/
    middleware/
    pages/
    plugins/
    utils/
  server/
    api/
  shared/
  public/
  nuxt.config.ts
  tsconfig.json
  package.json

The announcement gives two reasons for the move:

  • Faster file watching. With code at the project root, file watchers also scanned node_modules/ and .git/. Moving app code one level down lets Nuxt watch a smaller tree, which the announcement says matters most on Windows and Linux.
  • Clearer contexts. The split tells the editor which files are client code and which are server code, which is what makes the TypeScript separation below possible.

~ and @ now point at app/, so ~/components/Button.vue still resolves once the folder has moved.

Nothing forces the move today. Nuxt detects a project that still has pages/ at the root and keeps using it. If you want to upgrade the framework now and move the folders later, pin the old layout explicitly so the detection cannot surprise you:

nuxt.config.ts
export default defineNuxtConfig({
  srcDir: '.',
  dir: {
    app: 'app',
  },
})

srcDir: '.' keeps your application code at the root. dir.app is the folder where Nuxt looks for router.options.ts and spa-loading-template.html, so they stay in app/ as they were in Nuxt 3.

Data fetching: the defaults that changed

Here is a page written for the 4.0 defaults. It loads one post by the id in its URL:

app/pages/posts/[id].vue
<script setup lang="ts">
type Post = { id: number; title: string; body: string }

const route = useRoute()
const id = computed(() => Number(route.params.id))

const { data: post, status, error, refresh } = await useFetch<Post>(() => `/api/posts/${id.value}`)
</script>

<template>
  <p v-if="status === 'pending'">Loading…</p>
  <p v-else-if="error">Could not load this post.</p>
  <article v-else-if="post">
    <h1>{{ post.title }}</h1>
    <p>{{ post.body }}</p>
    <button type="button" @click="refresh()">Reload</button>
  </article>
</template>

Four of the changes show up in this page:

  • data is shallow. post is a shallowRef. Replacing post.value updates the page; writing post.value.title = 'Draft' does not. If a page edits fetched data in place, pass { deep: true } to that one call rather than switching the project-wide default back, which the guide advises against.
  • Empty means undefined. Before the first response, post is undefined, not null. So v-else-if="post" is the right guard, and an === null check no longer catches the empty state.
  • pending follows status. pending is now true only while status is 'pending', which is why the template reads status directly. With immediate: false, pending stays false until the first request instead of starting true.
  • A reactive URL refetches. The URL is a function of id, so moving from one post to another fetches the new one. The guide notes one edge: with immediate: false, a key change refetches only after a first fetch has happened, so trigger the first one yourself.

Calls that share a key now share state. Two components calling useFetch with the same key get the same data, error and status refs, so they must agree on options: in development, Nuxt warns when two calls with one key use a different transform, pick, default or deep. And refresh({ dedupe: true }) is gone: pass 'cancel' to replace the running request or 'defer' to keep it.

TypeScript: separate projects and a stricter check

Nuxt now generates tsconfig.app.json, tsconfig.server.json, tsconfig.shared.json and tsconfig.node.json (for build-time code such as nuxt.config.ts) inside .nuxt/. A new project's root tsconfig.json only references them:

tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./.nuxt/tsconfig.app.json" },
    { "path": "./.nuxt/tsconfig.server.json" },
    { "path": "./.nuxt/tsconfig.shared.json" },
    { "path": "./.nuxt/tsconfig.node.json" }
  ]
}

An upgraded project does not have to switch: its single extends line keeps working. To opt in, replace the file with the one above and run vue-tsc -b --noEmit instead of vue-tsc --noEmit. After the switch, type augmentations (declare module files) need to sit in app/, server/ or shared/ to reach the right project; one in a root types/ folder is no longer picked up.

The announcement expects the TypeScript change to cause more surprises than any other. One of them reaches every project, switched or not: noUncheckedIndexedAccess is now on, so TypeScript treats items[0] as possibly undefined, and items[0].name becomes an error until you handle that case. Fix those where you can. If the backlog is too long for one afternoon, typescript.tsConfig.compilerOptions.noUncheckedIndexedAccess: false in nuxt.config.ts turns the rule back off.

Other breaking changes and their opt-outs

The upgrade guide rates each change by impact and gives an opt-out for most. Beyond the folders, the data layer and TypeScript, these are the ones a typical app can meet:

  • Component names are normalised to match auto-import names, so a component in components/forms/Input.vue is FormsInput in findComponent and in <KeepAlive include>. Opt out with experimental.normalizeComponentNames: false.
  • Unhead v2: the vmid, hid, children and body meta props are removed. Opt out with unhead.legacy: true while you clean up.
  • route.meta.name and route.meta.path are no longer duplicated from definePageMeta; read route.name and route.path.
  • The SPA loading template renders next to #__nuxt rather than inside it, which can move a CSS selector.
  • error.data is parsed for you in error.vue; a JSON.parse(error.data) now throws.
  • Only component CSS is inlined in the HTML; global CSS is linked. features.inlineStyles: true restores the old behaviour.
  • generate.routes and generate.exclude move to nitro.prerender.routes and nitro.prerender.ignore.
  • window.__NUXT__ is removed after hydration; read useNuxtApp().payload.
  • Modules from layers now load before the project's own, so the project's modules win.
  • Setting compatibilityVersion: 3 no longer has any effect.

Module authors have a few more: builder:watch emits absolute paths and the lodash/template based template helpers are gone (both with codemods), and @nuxt/kit drops Nuxt 2 support.

Upgrade now or wait for your modules

Upgrade this week if every module in your nuxt.config.ts has a release that declares Nuxt 4 support in its changelog or README. Wait if a module you depend on has not: the announcement warns that a few modules may need updates for full Nuxt 4 compatibility, and no codemod can update a module for you. Nuxt 3's maintenance window makes waiting cheap.

The announcement also says what the next major brings: new major versions of Nitro and h3, and the Vite Environment API. None of it needs anything from you yet.

How to upgrade

Check Node first: Nuxt 4.0 needs Node 20.19 or a later 20.x, or Node 22.12 or later (Nuxt 3.17 accepted 20.9 and 22.0). Then run two commands:

npx nuxt upgrade --dedupe
npx codemod@latest nuxt/4/migration-recipe
  • The first updates Nuxt and deduplicates your lockfile; the announcement says this helps pull in updates to the unjs packages Nuxt relies on.
  • The second runs the migration recipe from the Codemod registry, which the announcement calls optional. It moves the folders into app/, rewrites === null checks on fetched data to === undefined, replaces boolean dedupe values and handles the builder:watch and template-helper changes for module authors. You can deselect any codemod you do not want to run.

One codemod in the recipe works against the new default: nuxt/4/shallow-function-reactivity adds { deep: true } to useFetch and useAsyncData calls written with a single argument, such as useFetch('/api/test'), which keeps Nuxt 3's deep data for them. Deselect it if you want shallow data, and add deep: true by hand where a page edits fetched data in place.

Then run your test suite, nuxt build and your type check. Fix the TypeScript errors first, then work through the list above for anything that is behaviour rather than types.

Check it stuck3 questions from the State: useState, Pinia and cookies quiz.
Practice now
Was this helpful?

Keep reading