What's New in Nuxt 4?

Jord
Product Engineer & Founder
Nuxt 4 is here. After a year of testing behind a compatibility flag in Nuxt 3.x, it's now the default. If you were expecting a dramatic rewrite — this isn't that. The team has been shipping features continuously through minor releases, so Nuxt 4 is less about new capabilities and more about cleaning house. Smarter defaults, a better project structure, and a handful of breaking changes that were long overdue.
Here's what actually matters.
The New app/ Directory
This is the most visible change. Your application code now lives in an app/ directory by default, separate from config, server code, and tooling files.
my-project/
├── app/
│ ├── assets/
│ ├── components/
│ ├── composables/
│ ├── layouts/
│ ├── middleware/
│ ├── pages/
│ ├── plugins/
│ ├── utils/
│ ├── app.vue
│ └── app.config.ts
├── content/
├── public/
├── server/
├── shared/
└── nuxt.config.ts
The rationale is practical. Separating app code from node_modules/, .git/, and config files means faster file watchers, especially on Windows and Linux. It also gives your IDE better context about what's client code and what's server code.
The good news: if you don't want to restructure your project right now, you don't have to. Nuxt detects the old structure and keeps working. You can also run the codemod to migrate automatically:
npx codemod@latest nuxt/4/file-structure
Or set it manually in your config:
export default defineNuxtConfig({
srcDir: '.',
dir: { app: 'app' },
})
Data Fetching Gets Smarter
The data fetching layer saw several meaningful changes. The biggest: useAsyncData and useFetch calls with the same key now share their data automatically across components. If two components fetch the same data, they get the same refs. No duplicate requests, no sync issues.
There are some behaviour changes to be aware of:
Shallow reactivity by default. data from useAsyncData and useFetch is now a shallowRef. Replacing the whole object triggers reactivity, but mutating nested properties doesn't. If you need deep reactivity, opt in per-call:
const { data } = useFetch('/api/users', { deep: true })
Or project-wide:
export default defineNuxtConfig({
experimental: {
defaults: {
useAsyncData: { deep: true },
},
},
})
data and error default to undefined instead of null. This is a small change but will bite you if you're doing strict equality checks:
// Before
if (data.value !== null) { }
// After
if (data.value !== undefined) { }
getCachedData gets a context object. You can now see why data is being refetched — was it a manual refresh, a hook, or a watch trigger:
useAsyncData('users', fetchUsers, {
getCachedData: (key, nuxtApp, ctx) => {
if (ctx.cause === 'refresh:manual') return undefined
return nuxtApp.payload.data[key]
}
})
Shared prerender data. During prerendering, fetched data is shared across pages. This means your keys need to be unique to the data they represent. If you're fetching route-specific data, include the route param in the key:
const route = useRoute()
// Bad — same key for every slug
const { data } = await useAsyncData(async () => {
return await $fetch(`/api/page/${route.params.slug}`)
})
// Good — key reflects the actual data
const { data } = await useAsyncData(route.params.slug, async () => {
return await $fetch(`/api/page/${route.params.slug}`)
})
TypeScript Gets Split Up
Nuxt now generates separate TypeScript projects for different contexts: app, server, shared, and builder. This means your server code won't autocomplete with client-side APIs, and vice versa. The type inference is more accurate across the board.
The practical upside: you only need one tsconfig.json in your project root. The downside: this is the change most likely to surface type errors that were previously hidden. The new default for noUncheckedIndexedAccess is true, which means array and object index access now returns T | undefined instead of T.
If that's too strict for your codebase right now:
export default defineNuxtConfig({
typescript: {
tsConfig: {
compilerOptions: {
noUncheckedIndexedAccess: false,
},
},
},
})
For better type checking, you can optionally adopt project references in your tsconfig:
{
"files": [],
"references": [
{ "path": "./.nuxt/tsconfig.app.json" },
{ "path": "./.nuxt/tsconfig.server.json" },
{ "path": "./.nuxt/tsconfig.shared.json" },
{ "path": "./.nuxt/tsconfig.node.json" }
]
}
Performance Improvements
The dev experience got faster in a few ways:
- Node.js compile cache is reused automatically, making cold starts quicker
- Native
fs.watchreplaces the previous file watching approach, reducing system resource usage - Socket-based CLI-to-Vite communication replaces network ports, cutting overhead especially on Windows
- Global CSS now uses
<link>tags instead of being inlined, which means better browser caching
None of these require any code changes. You just get a faster dev server.
Unhead v2
The head management library was updated to v2. A few things were removed:
vmidandhidprops are gone (they haven't been needed for a while)childrenprop removed — useinnerHTMLinstead- Tags are now sorted using Capo.js by default for optimal loading order
If you're using useHead or useSeoMeta in the standard way, you probably won't notice anything. If you need the old behaviour:
export default defineNuxtConfig({
unhead: { legacy: true },
})
Other Breaking Changes Worth Knowing
window.__NUXT__ is gone. If you were accessing it directly, use useNuxtApp().payload instead.
refresh({ dedupe: true }) removed. Use string values now:
// Before
await refresh({ dedupe: true })
// After
await refresh({ dedupe: 'cancel' })
Component names are normalized. Vue component names now match Nuxt's auto-import naming. components/SomeFolder/MyComponent.vue gets the name SomeFolderMyComponent. This affects findComponent in tests and <KeepAlive> includes.
Module loading order corrected. Layer modules now load before project modules, which is the intended behaviour. Most projects won't notice, but if you relied on the old (incorrect) order, you'll need to adjust.
How to Upgrade
The migration path is straightforward. Update Nuxt:
npx nuxt upgrade --dedupe
Then run the codemod to handle the mechanical changes automatically:
npx codemod@latest nuxt/4/migration-recipe
This covers the file structure migration, data/error default values, dedupe values, shallow reactivity changes, and template compilation updates.
After that, run your tests, check the build, and address any TypeScript issues that surface. Most projects should upgrade with minimal effort — the team spent a year testing these changes behind a compatibility flag for exactly that reason.
Final Thoughts
Nuxt 4 is a housekeeping release done right. The app/ directory structure is a genuine improvement for project organisation. The data fetching changes — shared refs, shallow reactivity, smarter caching — make the framework more predictable at scale. And the TypeScript split catches real bugs that were previously invisible.
There's no single headline feature, but the sum of the parts makes Nuxt feel more considered and production-ready. If you're on Nuxt 3, the upgrade is low risk and worth doing. Nuxt 3 maintenance ends in January 2026, so the clock is ticking regardless.
Nuxt 5 is already in the pipeline with Nitro v3, the Vite Environment API, and SSR streaming. But for now, 4.0 is a solid foundation.
Stay in the loop.
Weekly insights on building resilient systems, scaling solo SaaS, and the engineering behind it all.