Nuxt 3 reached end-of-life on July 31, 2026. No more security patches, no more bug fixes, no more compatibility updates — ever, for that line. If your package.json still says "nuxt": "^3", you're not behind on a nice-to-have; you're running unmaintained software in production. And the first time you run npm install nuxt@latest to fix that, something you didn't touch will break in a way the error message doesn't explain.
This article is written against Nuxt 4.6.0 (verified against the nuxt package's npm dist-tags in October 2026 — the 3x tag is frozen at 3.21.11, a patch that actually shipped August 5, 2026, five days after the cutoff; the last release before the cutoff itself was 3.21.10, on July 27, 2026). It walks through what the official Nuxt 4 release notes and upgrade guide actually say breaks, why those specific things were chosen to break, and the staged path that lets you fix each one without a single big-bang rewrite.
What you'll learn
By the end of this article you'll be able to:
- Explain why Nuxt 4 defaults to an
app/directory instead of treating it as cosmetic churn - Identify the exact conditions under which the new "Singleton Data Fetching Layer" breaks a
useAsyncData/useFetchcall that worked fine on Nuxt 3 - Fix the
null→undefineddefault change before it silently passes a staleif (data.value === null)check - Use
future.compatibilityVersion: 4to surface every breaking change while your app is still running on the Nuxt 3 package - Run the official codemod instead of hand-editing import paths across a whole codebase
Who this is for
You have a Nuxt 3 app in production, or you're planning a new one and want to know what "Nuxt 4" actually changes before you commit to it. You don't need prior Nitro or Nuxt-internals knowledge — just familiarity with useAsyncData/useFetch and a standard nuxt.config.ts.
Table of contents
- The problem: the upgrade that isn't a patch bump
- The mental model: Nuxt 4 is about boundaries, not folders
- Stage 1: the directory default, and why back-compat usually saves you
- Stage 2: the Singleton Data Fetching Layer
- Stage 3: null becomes undefined, and shallowRef replaces ref
- Stage 4: the window.NUXT removal and the TypeScript surprise
- Stage 5: the staged migration path
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
The problem: the upgrade that isn't a patch bump
Here's the instinct that gets people in trouble: Nuxt 3 minor versions have been safe to bump for years, so nuxt@4 looks like just the next number in the same sequence. A team runs the upgrade on a Friday afternoon, the dev server boots, the homepage renders, and it ships.
Monday morning, two unrelated bugs show up:
// A composable that worked for a year
const { data: user } = useAsyncData('current-user', () => $fetch('/api/me'))
// ...later, in a completely different component:
if (user.value === null) {
// this branch used to run before the fetch resolved — now it never does
showGuestBanner()
}
The guest banner stops appearing for logged-out users on first paint. Nobody touched useAsyncData. Nobody touched the banner component. The bug is two features away from the code that actually changed, because data now defaults to undefined, not null — and undefined === null is false.
That's the shape of almost every Nuxt 4 migration bug: not a loud crash, a quiet behavioral default that moved. The fix for all of them is the same shape too — know the list, grep for it, fix it deliberately — which is what the rest of this article gives you.
The mental model: Nuxt 4 is about boundaries, not folders
The mental model: every headline Nuxt 4 change is Nuxt drawing a boundary it used to leave implicit, and then enforcing it. Nuxt 3 inferred almost everything from one root directory and a lot of convention; Nuxt 4 makes several of those conventions into contracts that tooling — the file watcher, the TypeScript project, the data layer — can actually rely on.
Once you see it that way, the "random" list of breaking changes stops being random:
- The
app/directory isn't a cosmetic rename. It's Nuxt declaring "this subtree is the client application" so your file watcher can ignorenode_modules/and.git/more aggressively, and your IDE can tell client code from server code by path alone. - The Singleton Data Fetching Layer isn't a performance tweak. It's Nuxt declaring "a key is one fetch, for real this time" — if two calls share a key, they now must agree on how that fetch behaves, because they're genuinely the same underlying state, not two independent calls that happen to collide.
- The TypeScript changes aren't new bugs. They're Nuxt's project references finally being strict enough to surface type errors that were always there, just previously invisible to the compiler.
Read every section below through that lens: what boundary is this enforcing, and what breaks when my code quietly depended on that boundary being fuzzy?
Stage 1: the directory default, and why back-compat usually saves you
Nuxt 4's advertised headline change is that application code — components/, pages/, layouts/, app.vue — lives under app/ by default, while public/, shared/, server/, and nuxt.config.ts stay at the project root:
my-app/
├─ app/
│ ├─ app.vue
│ ├─ components/
│ └─ pages/
├─ server/
├─ public/
└─ nuxt.config.ts
Key concept: if your project already has this shape (most Nuxt 3 starters from the last year or two already do), nothing changes — Nuxt detects the existing layout and keeps it working. This is the part of the migration that generates the most noise online and causes the least real breakage.
Two situations where it does bite:
-
A custom
srcDir. If you've setsrcDirinnuxt.config.ts, Nuxt 4 resolvesmodules/,public/,shared/, andserver/from your project root instead of from that customsrcDir— the opposite of Nuxt 3's behavior. Override it explicitly withdir.modules,dir.public, andserverDirif you need the old resolution. -
You want to keep the flat Nuxt 3 layout on purpose. Set
srcDir: '.'together withdir.appinnuxt.config.ts, and nothing has to move.
If you do want to adopt the new layout, Nuxt ships a codemod rather than asking you to move files by hand:
npx codemod@latest nuxt/4/file-structure
Moving files is optional. Everything else in this article is not.
Stage 2: the Singleton Data Fetching Layer
This is the change most teams don't see coming, because it looks like useAsyncData just works differently now rather than looking like a documented breaking change.
In Nuxt 3, two useAsyncData/useFetch calls that happened to share a key were already treated as "the same fetch" in practice — the previous episode in this series covers exactly that sharing behavior and the auto-key bug it causes. Nuxt 4 takes that same-key-means-same-fetch idea and makes it a hard contract: calls sharing a key now must agree on handler, deep, transform, pick, getCachedData, serialize, default, and middleware. Mismatch any of them, and Nuxt logs a development-mode warning — but it doesn't block anything, and you still end up with inconsistent state instead of the two independent results you might expect:
// Component A
const { data } = useAsyncData('product-101', () => $fetch('/api/products/101'), {
transform: (p) => ({ ...p, displayName: p.name.toUpperCase() }),
})
// Component B — same key, different transform
const { data } = useAsyncData('product-101', () => $fetch('/api/products/101'), {
transform: (p) => p.name, // just the string
})
// Whichever call's options "win" depends on render order — this is the bug class.
// Nuxt warns about the mismatch in dev (a console warning, not a thrown error),
// but it doesn't stop the build and it doesn't fix the data — you still have to.
The fix: if two call sites legitimately need the same key, move the call into one shared composable so the options live in exactly one place, instead of being copy-pasted (and drifting) at each call site:
// app/composables/useProduct.ts
export function useProduct(id: string) {
return useAsyncData(`product-${id}`, () => $fetch(`/api/products/${id}`), {
transform: (p) => ({ ...p, displayName: p.name.toUpperCase() }),
})
}
The other half of this change is getCachedData. It used to matter mostly on the initial load; in Nuxt 4 it's called on every fetch for that key — including a watch-triggered refetch or a manual refreshNuxtData() — and it receives a cause ('initial' | 'refresh:hook' | 'refresh:manual' | 'watch') so you can decide, per cause, whether to trust the cache:
const { data } = useAsyncData('dashboard-stats', () => $fetch('/api/stats'), {
getCachedData(key, nuxtApp, ctx) {
// Always skip the cache on a manual refresh — the whole point was to get fresh data.
if (ctx.cause === 'refresh:manual') return undefined
return nuxtApp.payload.data[key]
},
})
Stage 3: null becomes undefined, and shallowRef replaces ref
Two smaller defaults sit next to the data layer change and are easy to miss in a changelog skim:
-
dataanderrornow default toundefined, notnull, when a fetch hasn't resolved yet or has no data. Grep your codebase for=== nulland!== nullanywhere near auseAsyncData/useFetchresult — every one of those is a candidate for the exact bug shown in "The problem" above. -
datais now ashallowRef, not a deepref. Mutating a nested property in place (data.value.items.push(x)) no longer triggers reactivity — you need to reassigndata.value(or callrefresh()) for the template to update. This trades a small amount of convenience for meaningfully better performance on large payloads, since Nuxt no longer has to deep-observe every field it fetches.
Both are one-line fixes once you know to look for them. Neither throws an error, which is exactly why they're worth grepping for deliberately rather than waiting for a bug report.
Stage 4: the window.NUXT removal and the TypeScript surprise
One small removal with a mechanical fix: window.__NUXT__ is removed after hydration completes, where it used to linger. Code reading it post-hydration (analytics scripts, debug snippets) needs to capture what it needs earlier — or read the same payload from useNuxtApp().payload instead, which Nuxt keeps around.
The bigger surprise isn't a removal at all, and it's opt-in rather than automatic: Nuxt 4 generates separate, context-specific TypeScript project references (tsconfig.app.json, tsconfig.server.json, tsconfig.node.json, tsconfig.shared.json) alongside the old merged tsconfig.json, and keeps the old one as the default for backwards compatibility — your existing root tsconfig.json keeps working, unchanged, until you point it at the new files. Once you do (a fresh Nuxt 4 project scaffolds it this way by default, and the migration codemod can set it up for an existing one), nuxt typecheck often jumps from zero errors to a page of them. That's not a new Nuxt 4 bug — each context now gets its own, more accurate globals and includes, so TypeScript can finally see code that was always slightly wrong.
Stage 5: the staged migration path
The official guide's real advice isn't "bump the package and fix what breaks" — it's to opt into Nuxt 4 behavior while still on the Nuxt 3 package, so you can fix issues one at a time with a fast feedback loop, before the cutover:
// nuxt.config.ts — still "nuxt": "^3.x" in package.json
export default defineNuxtConfig({
future: {
compatibilityVersion: 4,
},
})
With that flag set, your Nuxt 3 app runs with Nuxt 4's new defaults — the data layer contract, the null/undefined change, the directory resolution — so you see every real breakage in your own code, in your own dev server, without touching your dependency tree yet. Fix what it surfaces, commit, then bump nuxt itself to ^4.0.0 as a final, much smaller step.
Run the migration recipe codemod once you're ready for the mechanical renames (pin the version — @latest has a known issue with this one):
npx codemod@0.18.7 nuxt/4/migration-recipe
Edge cases and gotchas
-
Module authors feel this hardest. Nuxt 2/Bridge support is fully removed from
@nuxt/kit; check a module's own changelog for Nuxt 4 compatibility before upgrading an app that depends on it. -
compatibilityDateneeds bumping too. An old date pinned innuxt.config.tscan keep you on legacy Nitro behavior even after the package upgrade. -
Deep-mutating
useAsyncDataresults in place is the most common silent breakage from theshallowRefchange — the data changed, the UI just doesn't notice, which looks like an unrelated rendering bug.
Best practices
-
Flip
future.compatibilityVersion: 4before you touch thenuxtpackage version — the cheapest way to see your own breakage with the smallest blast radius. -
Grep for
=== null/!== nullnear everyuseAsyncData/useFetchcall before you upgrade, not after a bug report finds one for you. -
Centralize any
useAsyncDatacall two components legitimately share into one composable, so its options can't drift out of sync with the new matching requirement. -
If you adopt the new project-references
tsconfig.json(what a fresh Nuxt 4 project scaffolds, and what the migration codemod can set up for an existing one), runnuxt typecheckright away and treat what it surfaces as debt you already carried, not a new regression. -
Don't move files into
app/as a first step. It's the lowest bug-to-effort part of this migration; the data layer and default-value changes are where real bugs hide.
FAQ
Do I have to move my files into the app/ directory?
No. Nuxt 4 detects an existing Nuxt 3-style layout and keeps it working without any file moves. Moving to app/ is optional, and a codemod (npx codemod@latest nuxt/4/file-structure) does it for you if you choose to.
Will my app silently return wrong data after upgrading?
Only if two useAsyncData/useFetch calls share a key with mismatched options (transform, deep, pick, getCachedData, serialize, default, middleware, or the handler itself). Nuxt does log a development warning when it detects the mismatch, but it's a warning, not a thrown error — it doesn't stop the build, and the wrong data still ships unless you act on it. Centralizing shared-key calls into one composable removes the whole category of bug.
Is it safe to upgrade straight from an old Nuxt 3 version?
Yes, but the official guide's recommended path is to first set future.compatibilityVersion: 4 on your current Nuxt 3 app, fix everything it surfaces, and only then bump the nuxt package itself — rather than doing both at once.
What happens to Nuxt 3 apps that don't upgrade?
They keep running — npm packages don't disappear — but Nuxt 3 no longer receives security patches, bug fixes, or compatibility updates as of July 31, 2026. That risk grows, silently, the longer it's deferred.
Does this affect Nuxt modules I depend on, not just my own code?
Yes, independently of your own code. Nuxt 2/Bridge support is removed from @nuxt/kit, so a module that hasn't been updated for Nuxt 4 may break regardless of anything in this article. Check the module's own changelog before upgrading an app that relies on it.
Cheat sheet
| Change | Old (Nuxt 3) | New (Nuxt 4) | What to do |
|---|---|---|---|
| App code location | flat components/, pages/, root app.vue
|
app/components/, app/pages/, app/app.vue (back-compat if unchanged) |
Nothing required; npx codemod@latest nuxt/4/file-structure if you want to move |
Shared useAsyncData/useFetch key |
independent-feeling calls, loosely enforced |
handler/deep/transform/pick/getCachedData/serialize/default/middleware must match |
Centralize the call into one composable |
getCachedData |
mainly consulted on initial load | called on every fetch, receives { cause }
|
Branch on cause to skip stale cache on manual refresh |
data/error empty value |
null |
undefined |
Replace === null / !== null checks |
data reactivity |
deep ref
|
shallowRef |
Reassign data.value, don't deep-mutate in place |
public//assets/ server aliases |
resolved | removed | Use explicit paths or asset imports |
window.__NUXT__ |
present after hydration | removed after hydration | Read it before hydration completes, if you need it |
| Migration path | — |
future.compatibilityVersion: 4 on Nuxt 3, then bump the package |
Fix issues with the dependency unchanged first |
Key takeaways
- Nuxt 3 reached end-of-life on July 31, 2026 — this migration is no longer optional maintenance, it's closing a real security gap.
- The
app/directory default is backwards-compatible and rarely the thing that actually breaks; the data layer and default-value changes are. -
useAsyncData/useFetchcalls sharing a key now enforce matching options — centralize shared calls into one composable to guarantee it. -
data/errordefault toundefinedinstead ofnull, anddatais ashallowRef— both fail silently, not loudly, so grep for them rather than waiting for a bug report. -
future.compatibilityVersion: 4lets you fix every breaking change while still running the Nuxt 3 package, which is the official, lowest-risk path.
None of this is mysterious once you have the list — it's a short, specific set of boundaries Nuxt now enforces instead of leaving implicit. Run the compatibility flag, work the list once, and the actual package bump becomes the smallest, most boring step in the whole migration.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
What's the first thing on this list you'd find if you grepped your own app for it right now?
📚 Read next
- Nuxt Hydration Mismatch: Why It Happens and How to Fix It
- useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bug
- Nuxt 4.5 SSR Streaming: The Route Rules That Disable It
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)