[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-nuxt-weekly-4-migration-breaking-changes":44,"search-suggestions":60,"quiz-article-nuxt-weekly-4-migration-breaking-changes":106},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a11d02-8b71-718d-bbfa-3720003ffc7d","nuxt-weekly-4-migration-breaking-changes","PRACTICE_QUIZ","Nuxt 4 Migration — test yourself","Eight questions on what actually breaks moving from Nuxt 3 to Nuxt 4: the directory default, the Singleton Data Fetching Layer, the null-to-undefined change, and the staged migration path.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,88,92,96,99,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",133,{"slug":66,"name":67,"articles":68},"javascript","Javascript",109,{"slug":70,"name":71,"articles":72},"frontend","Frontend",82,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",51,{"slug":78,"name":79,"articles":80},"css","Css",42,{"slug":82,"name":83,"articles":84},"performance","Performance",18,{"slug":86,"name":87,"articles":84},"typescript","Typescript",{"slug":89,"name":90,"articles":91},"react","React",16,{"slug":93,"name":94,"articles":95},"browser","Browser",12,{"slug":97,"name":98,"articles":95},"node","Node",{"slug":100,"name":101,"articles":102},"accessibility","Accessibility",9,{"slug":104,"name":105,"articles":102},"programming","Programming",{"id":107,"slug":46,"title":108,"subtitle":52,"excerpt":109,"coverUrl":110,"locale":13,"readingMinutes":111,"publishedAt":112,"viewCount":113,"likeCount":19,"commentCount":19,"author":114,"vertical":119,"topic":120,"tags":123,"_count":128,"playground":130,"body":132,"bodyMd":510,"seo":511,"translationGroupId":513,"series":514,"podcastUrl":52,"verticalId":5,"thread":541,"assessments":543,"translations":546,"quiz":548},"01a11d02-8b28-774b-9f36-fb66bece8e50","Nuxt 4 Migration: The Breaking Changes That Actually Bite","Nuxt 3 reached end-of-life on July 31, 2026. Here's exactly what breaks moving to Nuxt 4 — directories, data fetching, TypeScript — plus a cheat sheet.","\u002Fmedia\u002Fcovers\u002Fnuxt-weekly-4-migration-breaking-changes.png",14,"2026-10-11T12:26:26.492Z",35,{"id":115,"name":116,"username":117,"avatarUrl":52,"headline":118},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":121,"name":122},"nuxt","Nuxt",[124,125,126,127],{"slug":121,"name":122,"color":52},{"slug":66,"name":67,"color":52},{"slug":74,"name":75,"color":52},{"slug":62,"name":63,"color":52},{"assessments":129},1,{"slug":46,"title":131},"Nuxt 4 Singleton Data Fetching Layer — interactive model",{"blocks":133,"version":129},[134,138,141,146,149,158,161,164,167,182,186,189,192,198,201,204,207,210,213,219,222,225,228,233,236,239,244,247,252,255,258,261,264,268,271,275,278,282,285,288,293,296,300,303,306,309,312,316,319,322,326,329,335,338,346,349,353,356,359,362,365,368,372,375,378,381,384,433,436,444,447,450,453,456,459,462,465,468,471,474,477,480,483,486,492,495,498,501,504],{"id":135,"html":136,"type":137},"b1","\u003Cp>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 \u003Ccode>package.json\u003C\u002Fcode> still says \u003Ccode>&quot;nuxt&quot;: &quot;^3&quot;\u003C\u002Fcode>, you&#39;re not behind on a nice-to-have; you&#39;re running unmaintained software in production. And the first time you run \u003Ccode>npm install nuxt@latest\u003C\u002Fcode> to fix that, something you didn&#39;t touch will break in a way the error message doesn&#39;t explain.\u003C\u002Fp>","paragraph",{"id":139,"html":140,"type":137},"b2","\u003Cp>This article is written against \u003Cstrong>Nuxt 4.6.0\u003C\u002Fstrong> (verified against the \u003Ccode>nuxt\u003C\u002Fcode> package&#39;s npm dist-tags in October 2026 — the \u003Ccode>3x\u003C\u002Fcode> tag is frozen at 3.21.11, a patch that actually shipped August 5, 2026, five days \u003Cem>after\u003C\u002Fem> 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.\u003C\u002Fp>",{"id":142,"html":143,"text":144,"type":145,"level":31},"b3","What you&#39;ll learn","What you'll learn","heading",{"id":147,"html":148,"type":137},"b4","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":150,"type":151,"items":152,"ordered":18},"b5","list",[153,154,155,156,157],"Explain \u003Cem>why\u003C\u002Fem> Nuxt 4 defaults to an \u003Ccode>app\u002F\u003C\u002Fcode> directory instead of treating it as cosmetic churn","Identify the exact conditions under which the new &quot;Singleton Data Fetching Layer&quot; breaks a \u003Ccode>useAsyncData\u003C\u002Fcode>\u002F\u003Ccode>useFetch\u003C\u002Fcode> call that worked fine on Nuxt 3","Fix the \u003Ccode>null\u003C\u002Fcode> → \u003Ccode>undefined\u003C\u002Fcode> default change before it silently passes a stale \u003Ccode>if (data.value === null)\u003C\u002Fcode> check","Use \u003Ccode>future.compatibilityVersion: 4\u003C\u002Fcode> to 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",{"id":159,"html":160,"text":160,"type":145,"level":31},"b6","Who this is for",{"id":162,"html":163,"type":137},"b7","\u003Cp>You have a Nuxt 3 app in production, or you&#39;re planning a new one and want to know what &quot;Nuxt 4&quot; actually changes before you commit to it. You don&#39;t need prior Nitro or Nuxt-internals knowledge — just familiarity with \u003Ccode>useAsyncData\u003C\u002Fcode>\u002F\u003Ccode>useFetch\u003C\u002Fcode> and a standard \u003Ccode>nuxt.config.ts\u003C\u002Fcode>.\u003C\u002Fp>",{"id":165,"html":166,"text":166,"type":145,"level":31},"b8","Table of contents",{"id":168,"type":151,"items":169,"ordered":18},"b9",[170,171,172,173,174,175,176,177,178,179,180,181],"\u003Ca href=\"#the-problem-the-upgrade-that-isnt-a-patch-bump\">The problem: the upgrade that isn&#39;t a patch bump\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-nuxt-4-is-about-boundaries-not-folders\">The mental model: Nuxt 4 is about boundaries, not folders\u003C\u002Fa>","\u003Ca href=\"#stage-1-the-directory-default-and-why-back-compat-usually-saves-you\">Stage 1: the directory default, and why back-compat usually saves you\u003C\u002Fa>","\u003Ca href=\"#stage-2-the-singleton-data-fetching-layer\">Stage 2: the Singleton Data Fetching Layer\u003C\u002Fa>","\u003Ca href=\"#stage-3-null-becomes-undefined-and-shallowref-replaces-ref\">Stage 3: null becomes undefined, and shallowRef replaces ref\u003C\u002Fa>","\u003Ca href=\"#stage-4-the-windownuxt-removal-and-the-typescript-surprise\">Stage 4: the window.\u003Cstrong>NUXT\u003C\u002Fstrong> removal and the TypeScript surprise\u003C\u002Fa>","\u003Ca href=\"#stage-5-the-staged-migration-path\">Stage 5: the staged migration path\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge cases and gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices\">Best practices\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat sheet\u003C\u002Fa>","\u003Ca href=\"#key-takeaways\">Key takeaways\u003C\u002Fa>",{"id":183,"html":184,"text":185,"type":145,"level":31},"b10","The problem: the upgrade that isn&#39;t a patch bump","The problem: the upgrade that isn't a patch bump",{"id":187,"html":188,"type":137},"b11","\u003Cp>Here&#39;s the instinct that gets people in trouble: Nuxt 3 minor versions have been safe to bump for years, so \u003Ccode>nuxt@4\u003C\u002Fcode> \u003Cem>looks\u003C\u002Fem> 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.\u003C\u002Fp>",{"id":190,"html":191,"type":137},"b12","\u003Cp>Monday morning, two unrelated bugs show up:\u003C\u002Fp>",{"id":193,"code":194,"type":195,"language":196,"highlight":197},"b13","\u002F\u002F A composable that worked for a year\nconst { data: user } = useAsyncData('current-user', () => $fetch('\u002Fapi\u002Fme'))\n\n\u002F\u002F ...later, in a completely different component:\nif (user.value === null) {\n  \u002F\u002F this branch used to run before the fetch resolved — now it never does\n  showGuestBanner()\n}","code","ts",[],{"id":199,"html":200,"type":137},"b14","\u003Cp>The guest banner stops appearing for logged-out users on first paint. Nobody touched \u003Ccode>useAsyncData\u003C\u002Fcode>. Nobody touched the banner component. The bug is two features away from the code that actually changed, because \u003Ccode>data\u003C\u002Fcode> now defaults to \u003Ccode>undefined\u003C\u002Fcode>, not \u003Ccode>null\u003C\u002Fcode> — and \u003Ccode>undefined === null\u003C\u002Fcode> is \u003Ccode>false\u003C\u002Fcode>.\u003C\u002Fp>",{"id":202,"html":203,"type":137},"b15","\u003Cp>That&#39;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.\u003C\u002Fp>",{"id":205,"html":206,"text":206,"type":145,"level":31},"b16","The mental model: Nuxt 4 is about boundaries, not folders",{"id":208,"html":209,"type":137},"b17","\u003Cp>\u003Cstrong>The mental model:\u003C\u002Fstrong> 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.\u003C\u002Fp>",{"id":211,"html":212,"type":137},"b18","\u003Cp>Once you see it that way, the &quot;random&quot; list of breaking changes stops being random:\u003C\u002Fp>",{"id":214,"type":151,"items":215,"ordered":18},"b19",[216,217,218],"The \u003Cstrong>\u003Ccode>app\u002F\u003C\u002Fcode> directory\u003C\u002Fstrong> isn&#39;t a cosmetic rename. It&#39;s Nuxt declaring &quot;this subtree is the client application&quot; so your file watcher can ignore \u003Ccode>node_modules\u002F\u003C\u002Fcode> and \u003Ccode>.git\u002F\u003C\u002Fcode> more aggressively, and your IDE can tell client code from server code by path alone.","The \u003Cstrong>Singleton Data Fetching Layer\u003C\u002Fstrong> isn&#39;t a performance tweak. It&#39;s Nuxt declaring &quot;a key is one fetch, for real this time&quot; — if two calls share a key, they now \u003Cem>must\u003C\u002Fem> agree on how that fetch behaves, because they&#39;re genuinely the same underlying state, not two independent calls that happen to collide.","The \u003Cstrong>TypeScript changes\u003C\u002Fstrong> aren&#39;t new bugs. They&#39;re Nuxt&#39;s project references finally being strict enough to surface type errors that were always there, just previously invisible to the compiler.",{"id":220,"html":221,"type":137},"b20","\u003Cp>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?\u003C\u002Fp>",{"id":223,"html":224,"text":224,"type":145,"level":31},"b21","Stage 1: the directory default, and why back-compat usually saves you",{"id":226,"html":227,"type":137},"b22","\u003Cp>Nuxt 4&#39;s advertised headline change is that application code — \u003Ccode>components\u002F\u003C\u002Fcode>, \u003Ccode>pages\u002F\u003C\u002Fcode>, \u003Ccode>layouts\u002F\u003C\u002Fcode>, \u003Ccode>app.vue\u003C\u002Fcode> — lives under \u003Ccode>app\u002F\u003C\u002Fcode> by default, while \u003Ccode>public\u002F\u003C\u002Fcode>, \u003Ccode>shared\u002F\u003C\u002Fcode>, \u003Ccode>server\u002F\u003C\u002Fcode>, and \u003Ccode>nuxt.config.ts\u003C\u002Fcode> stay at the project root:\u003C\u002Fp>",{"id":229,"code":230,"type":195,"language":231,"highlight":232},"b23","my-app\u002F\n├─ app\u002F\n│  ├─ app.vue\n│  ├─ components\u002F\n│  └─ pages\u002F\n├─ server\u002F\n├─ public\u002F\n└─ nuxt.config.ts","plain",[],{"id":234,"html":235,"type":137},"b24","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> 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.\u003C\u002Fp>",{"id":237,"html":238,"type":137},"b25","\u003Cp>Two situations where it \u003Cem>does\u003C\u002Fem> bite:\u003C\u002Fp>",{"id":240,"type":151,"items":241,"ordered":18},"b26",[242,243],"\u003Cstrong>A custom \u003Ccode>srcDir\u003C\u002Fcode>.\u003C\u002Fstrong> If you&#39;ve set \u003Ccode>srcDir\u003C\u002Fcode> in \u003Ccode>nuxt.config.ts\u003C\u002Fcode>, Nuxt 4 resolves \u003Ccode>modules\u002F\u003C\u002Fcode>, \u003Ccode>public\u002F\u003C\u002Fcode>, \u003Ccode>shared\u002F\u003C\u002Fcode>, and \u003Ccode>server\u002F\u003C\u002Fcode> from your project root instead of from that custom \u003Ccode>srcDir\u003C\u002Fcode> — the opposite of Nuxt 3&#39;s behavior. Override it explicitly with \u003Ccode>dir.modules\u003C\u002Fcode>, \u003Ccode>dir.public\u003C\u002Fcode>, and \u003Ccode>serverDir\u003C\u002Fcode> if you need the old resolution.","\u003Cstrong>You want to keep the flat Nuxt 3 layout on purpose.\u003C\u002Fstrong> Set \u003Ccode>srcDir: &#39;.&#39;\u003C\u002Fcode> together with \u003Ccode>dir.app\u003C\u002Fcode> in \u003Ccode>nuxt.config.ts\u003C\u002Fcode>, and nothing has to move.",{"id":245,"html":246,"type":137},"b27","\u003Cp>If you do want to adopt the new layout, Nuxt ships a codemod rather than asking you to move files by hand:\u003C\u002Fp>",{"id":248,"code":249,"type":195,"language":250,"highlight":251},"b28","npx codemod@latest nuxt\u002F4\u002Ffile-structure","bash",[],{"id":253,"html":254,"type":137},"b29","\u003Cp>Moving files is optional. Everything else in this article is not.\u003C\u002Fp>",{"id":256,"html":257,"text":257,"type":145,"level":31},"b30","Stage 2: the Singleton Data Fetching Layer",{"id":259,"html":260,"type":137},"b31","\u003Cp>This is the change most teams don&#39;t see coming, because it looks like \u003Ccode>useAsyncData\u003C\u002Fcode> just works differently now rather than looking like a documented breaking change.\u003C\u002Fp>",{"id":262,"html":263,"type":137},"b32","\u003Cp>In Nuxt 3, two \u003Ccode>useAsyncData\u003C\u002Fcode>\u002F\u003Ccode>useFetch\u003C\u002Fcode> calls that happened to share a key were already treated as &quot;the same fetch&quot; in practice — \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fuseasyncdata-keys-in-nuxt-caching-dedupe-the-sharing-bug-el1\">the previous episode in this series\u003C\u002Fa> 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 \u003Cstrong>must\u003C\u002Fstrong> agree on \u003Ccode>handler\u003C\u002Fcode>, \u003Ccode>deep\u003C\u002Fcode>, \u003Ccode>transform\u003C\u002Fcode>, \u003Ccode>pick\u003C\u002Fcode>, \u003Ccode>getCachedData\u003C\u002Fcode>, \u003Ccode>serialize\u003C\u002Fcode>, \u003Ccode>default\u003C\u002Fcode>, and \u003Ccode>middleware\u003C\u002Fcode>. Mismatch any of them, and Nuxt logs a development-mode warning — but it doesn&#39;t block anything, and you still end up with inconsistent state instead of the two independent results you might expect:\u003C\u002Fp>",{"id":265,"code":266,"type":195,"language":196,"highlight":267},"b33","\u002F\u002F Component A\nconst { data } = useAsyncData('product-101', () => $fetch('\u002Fapi\u002Fproducts\u002F101'), {\n  transform: (p) => ({ ...p, displayName: p.name.toUpperCase() }),\n})\n\n\u002F\u002F Component B — same key, different transform\nconst { data } = useAsyncData('product-101', () => $fetch('\u002Fapi\u002Fproducts\u002F101'), {\n  transform: (p) => p.name, \u002F\u002F just the string\n})\n\u002F\u002F Whichever call's options \"win\" depends on render order — this is the bug class.\n\u002F\u002F Nuxt warns about the mismatch in dev (a console warning, not a thrown error),\n\u002F\u002F but it doesn't stop the build and it doesn't fix the data — you still have to.",[],{"id":269,"html":270,"type":137},"b34","\u003Cp>\u003Cstrong>The fix:\u003C\u002Fstrong> 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:\u003C\u002Fp>",{"id":272,"code":273,"type":195,"language":196,"highlight":274},"b35","\u002F\u002F app\u002Fcomposables\u002FuseProduct.ts\nexport function useProduct(id: string) {\n  return useAsyncData(`product-${id}`, () => $fetch(`\u002Fapi\u002Fproducts\u002F${id}`), {\n    transform: (p) => ({ ...p, displayName: p.name.toUpperCase() }),\n  })\n}",[],{"id":276,"html":277,"type":137},"b36","\u003Cp>The other half of this change is \u003Ccode>getCachedData\u003C\u002Fcode>. It used to matter mostly on the initial load; in Nuxt 4 it&#39;s called on \u003Cstrong>every\u003C\u002Fstrong> fetch for that key — including a \u003Ccode>watch\u003C\u002Fcode>-triggered refetch or a manual \u003Ccode>refreshNuxtData()\u003C\u002Fcode> — and it receives a \u003Ccode>cause\u003C\u002Fcode> (&#39;initial&#39; | &#39;refresh:hook&#39; | &#39;refresh:manual&#39; | &#39;watch&#39;) so you can decide, per cause, whether to trust the cache:\u003C\u002Fp>",{"id":279,"code":280,"type":195,"language":196,"highlight":281},"b37","const { data } = useAsyncData('dashboard-stats', () => $fetch('\u002Fapi\u002Fstats'), {\n  getCachedData(key, nuxtApp, ctx) {\n    \u002F\u002F Always skip the cache on a manual refresh — the whole point was to get fresh data.\n    if (ctx.cause === 'refresh:manual') return undefined\n    return nuxtApp.payload.data[key]\n  },\n})",[],{"id":283,"html":284,"text":284,"type":145,"level":31},"b38","Stage 3: null becomes undefined, and shallowRef replaces ref",{"id":286,"html":287,"type":137},"b39","\u003Cp>Two smaller defaults sit next to the data layer change and are easy to miss in a changelog skim:\u003C\u002Fp>",{"id":289,"type":151,"items":290,"ordered":18},"b40",[291,292],"\u003Cstrong>\u003Ccode>data\u003C\u002Fcode> and \u003Ccode>error\u003C\u002Fcode> now default to \u003Ccode>undefined\u003C\u002Fcode>, not \u003Ccode>null\u003C\u002Fcode>\u003C\u002Fstrong>, when a fetch hasn&#39;t resolved yet or has no data. Grep your codebase for \u003Ccode>=== null\u003C\u002Fcode> and \u003Ccode>!== null\u003C\u002Fcode> anywhere near a \u003Ccode>useAsyncData\u003C\u002Fcode>\u002F\u003Ccode>useFetch\u003C\u002Fcode> result — every one of those is a candidate for the exact bug shown in &quot;The problem&quot; above.","\u003Cstrong>\u003Ccode>data\u003C\u002Fcode> is now a \u003Ccode>shallowRef\u003C\u002Fcode>, not a deep \u003Ccode>ref\u003C\u002Fcode>.\u003C\u002Fstrong> Mutating a nested property in place (\u003Ccode>data.value.items.push(x)\u003C\u002Fcode>) no longer triggers reactivity — you need to reassign \u003Ccode>data.value\u003C\u002Fcode> (or call \u003Ccode>refresh()\u003C\u002Fcode>) 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.",{"id":294,"html":295,"type":137},"b41","\u003Cp>Both are one-line fixes once you know to look for them. Neither throws an error, which is exactly why they&#39;re worth grepping for deliberately rather than waiting for a bug report.\u003C\u002Fp>",{"id":297,"html":298,"text":299,"type":145,"level":31},"b42","Stage 4: the window.\u003Cstrong>NUXT\u003C\u002Fstrong> removal and the TypeScript surprise","Stage 4: the window.NUXT removal and the TypeScript surprise",{"id":301,"html":302,"type":137},"b43","\u003Cp>One small removal with a mechanical fix: \u003Cstrong>\u003Ccode>window.__NUXT__\u003C\u002Fcode> is removed after hydration completes\u003C\u002Fstrong>, 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 \u003Ccode>useNuxtApp().payload\u003C\u002Fcode> instead, which Nuxt keeps around.\u003C\u002Fp>",{"id":304,"html":305,"type":137},"b44","\u003Cp>The bigger surprise isn&#39;t a removal at all, and it&#39;s opt-in rather than automatic: Nuxt 4 generates separate, context-specific TypeScript project references (\u003Ccode>tsconfig.app.json\u003C\u002Fcode>, \u003Ccode>tsconfig.server.json\u003C\u002Fcode>, \u003Ccode>tsconfig.node.json\u003C\u002Fcode>, \u003Ccode>tsconfig.shared.json\u003C\u002Fcode>) alongside the old merged \u003Ccode>tsconfig.json\u003C\u002Fcode>, and keeps the old one as the default for backwards compatibility — your existing root \u003Ccode>tsconfig.json\u003C\u002Fcode> keeps working, unchanged, until you point it at the new files. \u003Cstrong>Once you do\u003C\u002Fstrong> (a fresh Nuxt 4 project scaffolds it this way by default, and the migration codemod can set it up for an existing one), \u003Ccode>nuxt typecheck\u003C\u002Fcode> often jumps from zero errors to a page of them. That&#39;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.\u003C\u002Fp>",{"id":307,"html":308,"text":308,"type":145,"level":31},"b45","Stage 5: the staged migration path",{"id":310,"html":311,"type":137},"b46","\u003Cp>The official guide&#39;s real advice isn&#39;t &quot;bump the package and fix what breaks&quot; — it&#39;s to opt into Nuxt 4 behavior \u003Cem>while still on the Nuxt 3 package\u003C\u002Fem>, so you can fix issues one at a time with a fast feedback loop, before the cutover:\u003C\u002Fp>",{"id":313,"code":314,"type":195,"language":196,"highlight":315},"b47","\u002F\u002F nuxt.config.ts — still \"nuxt\": \"^3.x\" in package.json\nexport default defineNuxtConfig({\n  future: {\n    compatibilityVersion: 4,\n  },\n})",[],{"id":317,"html":318,"type":137},"b48","\u003Cp>With that flag set, your Nuxt 3 app runs with Nuxt 4&#39;s new defaults — the data layer contract, the \u003Ccode>null\u003C\u002Fcode>\u002F\u003Ccode>undefined\u003C\u002Fcode> 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 \u003Ccode>nuxt\u003C\u002Fcode> itself to \u003Ccode>^4.0.0\u003C\u002Fcode> as a final, much smaller step.\u003C\u002Fp>",{"id":320,"html":321,"type":137},"b49","\u003Cp>Run the migration recipe codemod once you&#39;re ready for the mechanical renames (pin the version — \u003Ccode>@latest\u003C\u002Fcode> has a known issue with this one):\u003C\u002Fp>",{"id":323,"code":324,"type":195,"language":250,"highlight":325},"b50","npx codemod@0.18.7 nuxt\u002F4\u002Fmigration-recipe",[],{"id":327,"html":328,"text":328,"type":145,"level":31},"b51","Edge cases and gotchas",{"id":330,"type":151,"items":331,"ordered":18},"b52",[332,333,334],"\u003Cstrong>Module authors feel this hardest.\u003C\u002Fstrong> Nuxt 2\u002FBridge support is fully removed from \u003Ccode>@nuxt\u002Fkit\u003C\u002Fcode>; check a module&#39;s own changelog for Nuxt 4 compatibility before upgrading an app that depends on it.","\u003Cstrong>\u003Ccode>compatibilityDate\u003C\u002Fcode> needs bumping too.\u003C\u002Fstrong> An old date pinned in \u003Ccode>nuxt.config.ts\u003C\u002Fcode> can keep you on legacy Nitro behavior even after the package upgrade.","\u003Cstrong>Deep-mutating \u003Ccode>useAsyncData\u003C\u002Fcode> results in place\u003C\u002Fstrong> is the most common silent breakage from the \u003Ccode>shallowRef\u003C\u002Fcode> change — the data changed, the UI just doesn&#39;t notice, which looks like an unrelated rendering bug.",{"id":336,"html":337,"text":337,"type":145,"level":31},"b53","Best practices",{"id":339,"type":151,"items":340,"ordered":18},"b54",[341,342,343,344,345],"\u003Cstrong>Flip \u003Ccode>future.compatibilityVersion: 4\u003C\u002Fcode> before you touch the \u003Ccode>nuxt\u003C\u002Fcode> package version\u003C\u002Fstrong> — the cheapest way to see your own breakage with the smallest blast radius.","\u003Cstrong>Grep for \u003Ccode>=== null\u003C\u002Fcode> \u002F \u003Ccode>!== null\u003C\u002Fcode>\u003C\u002Fstrong> near every \u003Ccode>useAsyncData\u003C\u002Fcode>\u002F\u003Ccode>useFetch\u003C\u002Fcode> call before you upgrade, not after a bug report finds one for you.","\u003Cstrong>Centralize any \u003Ccode>useAsyncData\u003C\u002Fcode> call two components legitimately share\u003C\u002Fstrong> into one composable, so its options can&#39;t drift out of sync with the new matching requirement.","\u003Cstrong>If you adopt the new project-references \u003Ccode>tsconfig.json\u003C\u002Fcode>\u003C\u002Fstrong> (what a fresh Nuxt 4 project scaffolds, and what the migration codemod can set up for an existing one), run \u003Ccode>nuxt typecheck\u003C\u002Fcode> right away and treat what it surfaces as debt you already carried, not a new regression.","\u003Cstrong>Don&#39;t move files into \u003Ccode>app\u002F\u003C\u002Fcode> as a first step.\u003C\u002Fstrong> It&#39;s the lowest bug-to-effort part of this migration; the data layer and default-value changes are where real bugs hide.",{"id":347,"html":348,"text":348,"type":145,"level":31},"b55","FAQ",{"id":350,"html":351,"text":352,"type":145,"level":43},"b56","Do I have to move my files into the \u003Ccode>app\u002F\u003C\u002Fcode> directory?","Do I have to move my files into the app\u002F directory?",{"id":354,"html":355,"type":137},"b57","\u003Cp>No. Nuxt 4 detects an existing Nuxt 3-style layout and keeps it working without any file moves. Moving to \u003Ccode>app\u002F\u003C\u002Fcode> is optional, and a codemod (\u003Ccode>npx codemod@latest nuxt\u002F4\u002Ffile-structure\u003C\u002Fcode>) does it for you if you choose to.\u003C\u002Fp>",{"id":357,"html":358,"text":358,"type":145,"level":43},"b58","Will my app silently return wrong data after upgrading?",{"id":360,"html":361,"type":137},"b59","\u003Cp>Only if two \u003Ccode>useAsyncData\u003C\u002Fcode>\u002F\u003Ccode>useFetch\u003C\u002Fcode> calls share a key with mismatched options (\u003Ccode>transform\u003C\u002Fcode>, \u003Ccode>deep\u003C\u002Fcode>, \u003Ccode>pick\u003C\u002Fcode>, \u003Ccode>getCachedData\u003C\u002Fcode>, \u003Ccode>serialize\u003C\u002Fcode>, \u003Ccode>default\u003C\u002Fcode>, \u003Ccode>middleware\u003C\u002Fcode>, or the \u003Ccode>handler\u003C\u002Fcode> itself). Nuxt does log a development warning when it detects the mismatch, but it&#39;s a warning, not a thrown error — it doesn&#39;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.\u003C\u002Fp>",{"id":363,"html":364,"text":364,"type":145,"level":43},"b60","Is it safe to upgrade straight from an old Nuxt 3 version?",{"id":366,"html":367,"type":137},"b61","\u003Cp>Yes, but the official guide&#39;s recommended path is to first set \u003Ccode>future.compatibilityVersion: 4\u003C\u002Fcode> on your current Nuxt 3 app, fix everything it surfaces, and only then bump the \u003Ccode>nuxt\u003C\u002Fcode> package itself — rather than doing both at once.\u003C\u002Fp>",{"id":369,"html":370,"text":371,"type":145,"level":43},"b62","What happens to Nuxt 3 apps that don&#39;t upgrade?","What happens to Nuxt 3 apps that don't upgrade?",{"id":373,"html":374,"type":137},"b63","\u003Cp>They keep running — npm packages don&#39;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&#39;s deferred.\u003C\u002Fp>",{"id":376,"html":377,"text":377,"type":145,"level":43},"b64","Does this affect Nuxt modules I depend on, not just my own code?",{"id":379,"html":380,"type":137},"b65","\u003Cp>Yes, independently of your own code. Nuxt 2\u002FBridge support is removed from \u003Ccode>@nuxt\u002Fkit\u003C\u002Fcode>, so a module that hasn&#39;t been updated for Nuxt 4 may break regardless of anything in this article. Check the module&#39;s own changelog before upgrading an app that relies on it.\u003C\u002Fp>",{"id":382,"html":383,"text":383,"type":145,"level":31},"b66","Cheat sheet",{"id":385,"head":386,"rows":391,"type":432},"b67",[387,388,389,390],"Change","Old (Nuxt 3)","New (Nuxt 4)","What to do",[392,397,402,407,412,417,422,427],[393,394,395,396],"App code location","flat \u003Ccode>components\u002F\u003C\u002Fcode>, \u003Ccode>pages\u002F\u003C\u002Fcode>, root \u003Ccode>app.vue\u003C\u002Fcode>","\u003Ccode>app\u002Fcomponents\u002F\u003C\u002Fcode>, \u003Ccode>app\u002Fpages\u002F\u003C\u002Fcode>, \u003Ccode>app\u002Fapp.vue\u003C\u002Fcode> (back-compat if unchanged)","Nothing required; \u003Ccode>npx codemod@latest nuxt\u002F4\u002Ffile-structure\u003C\u002Fcode> if you want to move",[398,399,400,401],"Shared \u003Ccode>useAsyncData\u003C\u002Fcode>\u002F\u003Ccode>useFetch\u003C\u002Fcode> key","independent-feeling calls, loosely enforced","\u003Ccode>handler\u003C\u002Fcode>\u002F\u003Ccode>deep\u003C\u002Fcode>\u002F\u003Ccode>transform\u003C\u002Fcode>\u002F\u003Ccode>pick\u003C\u002Fcode>\u002F\u003Ccode>getCachedData\u003C\u002Fcode>\u002F\u003Ccode>serialize\u003C\u002Fcode>\u002F\u003Ccode>default\u003C\u002Fcode>\u002F\u003Ccode>middleware\u003C\u002Fcode> must match","Centralize the call into one composable",[403,404,405,406],"\u003Ccode>getCachedData\u003C\u002Fcode>","mainly consulted on initial load","called on every fetch, receives \u003Ccode>{ cause }\u003C\u002Fcode>","Branch on \u003Ccode>cause\u003C\u002Fcode> to skip stale cache on manual refresh",[408,409,410,411],"\u003Ccode>data\u003C\u002Fcode>\u002F\u003Ccode>error\u003C\u002Fcode> empty value","\u003Ccode>null\u003C\u002Fcode>","\u003Ccode>undefined\u003C\u002Fcode>","Replace \u003Ccode>=== null\u003C\u002Fcode> \u002F \u003Ccode>!== null\u003C\u002Fcode> checks",[413,414,415,416],"\u003Ccode>data\u003C\u002Fcode> reactivity","deep \u003Ccode>ref\u003C\u002Fcode>","\u003Ccode>shallowRef\u003C\u002Fcode>","Reassign \u003Ccode>data.value\u003C\u002Fcode>, don&#39;t deep-mutate in place",[418,419,420,421],"\u003Ccode>public\u002F\u003C\u002Fcode>\u002F\u003Ccode>assets\u002F\u003C\u002Fcode> server aliases","resolved","removed","Use explicit paths or asset imports",[423,424,425,426],"\u003Ccode>window.__NUXT__\u003C\u002Fcode>","present after hydration","removed after hydration","Read it before hydration completes, if you need it",[428,429,430,431],"Migration path","—","\u003Ccode>future.compatibilityVersion: 4\u003C\u002Fcode> on Nuxt 3, then bump the package","Fix issues with the dependency unchanged first","table",{"id":434,"html":435,"text":435,"type":145,"level":31},"b68","Key takeaways",{"id":437,"type":151,"items":438,"ordered":18},"b69",[439,440,441,442,443],"Nuxt 3 reached end-of-life on July 31, 2026 — this migration is no longer optional maintenance, it&#39;s closing a real security gap.","The \u003Ccode>app\u002F\u003C\u002Fcode> directory default is backwards-compatible and rarely the thing that actually breaks; the data layer and default-value changes are.","\u003Ccode>useAsyncData\u003C\u002Fcode>\u002F\u003Ccode>useFetch\u003C\u002Fcode> calls sharing a key now enforce matching options — centralize shared calls into one composable to guarantee it.","\u003Ccode>data\u003C\u002Fcode>\u002F\u003Ccode>error\u003C\u002Fcode> default to \u003Ccode>undefined\u003C\u002Fcode> instead of \u003Ccode>null\u003C\u002Fcode>, and \u003Ccode>data\u003C\u002Fcode> is a \u003Ccode>shallowRef\u003C\u002Fcode> — both fail silently, not loudly, so grep for them rather than waiting for a bug report.","\u003Ccode>future.compatibilityVersion: 4\u003C\u002Fcode> lets you fix every breaking change while still running the Nuxt 3 package, which is the official, lowest-risk path.",{"id":445,"html":446,"type":137},"b70","\u003Cp>None of this is mysterious once you have the list — it&#39;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.\u003C\u002Fp>",{"id":448,"html":449,"type":137},"b71","\u003C!-- playground:start -->",{"id":451,"html":452,"text":452,"type":145,"level":31},"b72","🎮 Try it yourself",{"id":454,"html":455,"type":137},"b73","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-4-migration-breaking-changes\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":457,"html":458,"type":137},"b74","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":460,"html":461,"type":137},"b75","\u003C!-- playground:end -->",{"id":463,"html":464,"type":137},"b76","\u003C!-- quiz:start -->",{"id":466,"html":467,"text":467,"type":145,"level":31},"b77","🧠 Test yourself",{"id":469,"html":470,"type":137},"b78","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-4-migration-breaking-changes\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":472,"html":473,"type":137},"b79","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":475,"html":476,"type":137},"b80","\u003C!-- quiz:end -->",{"id":478,"html":479,"type":137},"b81","\u003Cp>What&#39;s the first thing on this list you&#39;d find if you grepped your own app for it right now?\u003C\u002Fp>",{"id":481,"html":482,"type":137},"b82","\u003C!-- related:start -->",{"id":484,"html":485,"text":485,"type":145,"level":31},"b83","📚 Read next",{"id":487,"type":151,"items":488,"ordered":18},"b84",[489,490,491],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-hydration-mismatch\">Nuxt Hydration Mismatch: Why It Happens and How to Fix It\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-useasyncdata-keys-dedupe\">useAsyncData Keys in Nuxt: Caching, Dedupe &amp; the Sharing Bug\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-ssr-streaming-route-rules\">Nuxt 4.5 SSR Streaming: The Route Rules That Disable It\u003C\u002Fa>",{"id":493,"html":494,"type":137},"b85","\u003C!-- related:end -->",{"id":496,"type":497},"b86","divider",{"id":499,"html":500,"type":137},"b87","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":502,"html":503,"type":137},"b88","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":505,"type":151,"items":506,"ordered":18},"b89",[507,508,509],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","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.\n\nThis 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.\n\n## What you'll learn\n\nBy the end of this article you'll be able to:\n\n- Explain *why* Nuxt 4 defaults to an `app\u002F` directory instead of treating it as cosmetic churn\n- Identify the exact conditions under which the new \"Singleton Data Fetching Layer\" breaks a `useAsyncData`\u002F`useFetch` call that worked fine on Nuxt 3\n- Fix the `null` → `undefined` default change before it silently passes a stale `if (data.value === null)` check\n- Use `future.compatibilityVersion: 4` to surface every breaking change while your app is still running on the Nuxt 3 package\n- Run the official codemod instead of hand-editing import paths across a whole codebase\n\n## Who this is for\n\nYou 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`\u002F`useFetch` and a standard `nuxt.config.ts`.\n\n## Table of contents\n\n- [The problem: the upgrade that isn't a patch bump](#the-problem-the-upgrade-that-isnt-a-patch-bump)\n- [The mental model: Nuxt 4 is about boundaries, not folders](#the-mental-model-nuxt-4-is-about-boundaries-not-folders)\n- [Stage 1: the directory default, and why back-compat usually saves you](#stage-1-the-directory-default-and-why-back-compat-usually-saves-you)\n- [Stage 2: the Singleton Data Fetching Layer](#stage-2-the-singleton-data-fetching-layer)\n- [Stage 3: null becomes undefined, and shallowRef replaces ref](#stage-3-null-becomes-undefined-and-shallowref-replaces-ref)\n- [Stage 4: the window.__NUXT__ removal and the TypeScript surprise](#stage-4-the-windownuxt-removal-and-the-typescript-surprise)\n- [Stage 5: the staged migration path](#stage-5-the-staged-migration-path)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices](#best-practices)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n- [Key takeaways](#key-takeaways)\n\n## The problem: the upgrade that isn't a patch bump\n\nHere'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.\n\nMonday morning, two unrelated bugs show up:\n\n```ts\n\u002F\u002F A composable that worked for a year\nconst { data: user } = useAsyncData('current-user', () => $fetch('\u002Fapi\u002Fme'))\n\n\u002F\u002F ...later, in a completely different component:\nif (user.value === null) {\n  \u002F\u002F this branch used to run before the fetch resolved — now it never does\n  showGuestBanner()\n}\n```\n\nThe 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`.\n\nThat'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.\n\n## The mental model: Nuxt 4 is about boundaries, not folders\n\n**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.\n\nOnce you see it that way, the \"random\" list of breaking changes stops being random:\n\n- The **`app\u002F` directory** isn't a cosmetic rename. It's Nuxt declaring \"this subtree is the client application\" so your file watcher can ignore `node_modules\u002F` and `.git\u002F` more aggressively, and your IDE can tell client code from server code by path alone.\n- 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.\n- 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.\n\nRead every section below through that lens: what boundary is this enforcing, and what breaks when my code quietly depended on that boundary being fuzzy?\n\n## Stage 1: the directory default, and why back-compat usually saves you\n\nNuxt 4's advertised headline change is that application code — `components\u002F`, `pages\u002F`, `layouts\u002F`, `app.vue` — lives under `app\u002F` by default, while `public\u002F`, `shared\u002F`, `server\u002F`, and `nuxt.config.ts` stay at the project root:\n\n```\nmy-app\u002F\n├─ app\u002F\n│  ├─ app.vue\n│  ├─ components\u002F\n│  └─ pages\u002F\n├─ server\u002F\n├─ public\u002F\n└─ nuxt.config.ts\n```\n\n**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.\n\nTwo situations where it *does* bite:\n\n- **A custom `srcDir`.** If you've set `srcDir` in `nuxt.config.ts`, Nuxt 4 resolves `modules\u002F`, `public\u002F`, `shared\u002F`, and `server\u002F` from your project root instead of from that custom `srcDir` — the opposite of Nuxt 3's behavior. Override it explicitly with `dir.modules`, `dir.public`, and `serverDir` if you need the old resolution.\n- **You want to keep the flat Nuxt 3 layout on purpose.** Set `srcDir: '.'` together with `dir.app` in `nuxt.config.ts`, and nothing has to move.\n\nIf you do want to adopt the new layout, Nuxt ships a codemod rather than asking you to move files by hand:\n\n```bash\nnpx codemod@latest nuxt\u002F4\u002Ffile-structure\n```\n\nMoving files is optional. Everything else in this article is not.\n\n## Stage 2: the Singleton Data Fetching Layer\n\nThis 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.\n\nIn Nuxt 3, two `useAsyncData`\u002F`useFetch` calls that happened to share a key were already treated as \"the same fetch\" in practice — [the previous episode in this series](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fuseasyncdata-keys-in-nuxt-caching-dedupe-the-sharing-bug-el1) 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:\n\n```ts\n\u002F\u002F Component A\nconst { data } = useAsyncData('product-101', () => $fetch('\u002Fapi\u002Fproducts\u002F101'), {\n  transform: (p) => ({ ...p, displayName: p.name.toUpperCase() }),\n})\n\n\u002F\u002F Component B — same key, different transform\nconst { data } = useAsyncData('product-101', () => $fetch('\u002Fapi\u002Fproducts\u002F101'), {\n  transform: (p) => p.name, \u002F\u002F just the string\n})\n\u002F\u002F Whichever call's options \"win\" depends on render order — this is the bug class.\n\u002F\u002F Nuxt warns about the mismatch in dev (a console warning, not a thrown error),\n\u002F\u002F but it doesn't stop the build and it doesn't fix the data — you still have to.\n```\n\n**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:\n\n```ts\n\u002F\u002F app\u002Fcomposables\u002FuseProduct.ts\nexport function useProduct(id: string) {\n  return useAsyncData(`product-${id}`, () => $fetch(`\u002Fapi\u002Fproducts\u002F${id}`), {\n    transform: (p) => ({ ...p, displayName: p.name.toUpperCase() }),\n  })\n}\n```\n\nThe 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:\n\n```ts\nconst { data } = useAsyncData('dashboard-stats', () => $fetch('\u002Fapi\u002Fstats'), {\n  getCachedData(key, nuxtApp, ctx) {\n    \u002F\u002F Always skip the cache on a manual refresh — the whole point was to get fresh data.\n    if (ctx.cause === 'refresh:manual') return undefined\n    return nuxtApp.payload.data[key]\n  },\n})\n```\n\n## Stage 3: null becomes undefined, and shallowRef replaces ref\n\nTwo smaller defaults sit next to the data layer change and are easy to miss in a changelog skim:\n\n- **`data` and `error` now default to `undefined`, not `null`**, when a fetch hasn't resolved yet or has no data. Grep your codebase for `=== null` and `!== null` anywhere near a `useAsyncData`\u002F`useFetch` result — every one of those is a candidate for the exact bug shown in \"The problem\" above.\n- **`data` is now a `shallowRef`, not a deep `ref`.** Mutating a nested property in place (`data.value.items.push(x)`) no longer triggers reactivity — you need to reassign `data.value` (or call `refresh()`) 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.\n\nBoth 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.\n\n## Stage 4: the window.__NUXT__ removal and the TypeScript surprise\n\nOne 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.\n\nThe 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.\n\n## Stage 5: the staged migration path\n\nThe 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:\n\n```ts\n\u002F\u002F nuxt.config.ts — still \"nuxt\": \"^3.x\" in package.json\nexport default defineNuxtConfig({\n  future: {\n    compatibilityVersion: 4,\n  },\n})\n```\n\nWith that flag set, your Nuxt 3 app runs with Nuxt 4's new defaults — the data layer contract, the `null`\u002F`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.\n\nRun the migration recipe codemod once you're ready for the mechanical renames (pin the version — `@latest` has a known issue with this one):\n\n```bash\nnpx codemod@0.18.7 nuxt\u002F4\u002Fmigration-recipe\n```\n\n## Edge cases and gotchas\n\n- **Module authors feel this hardest.** Nuxt 2\u002FBridge support is fully removed from `@nuxt\u002Fkit`; check a module's own changelog for Nuxt 4 compatibility before upgrading an app that depends on it.\n- **`compatibilityDate` needs bumping too.** An old date pinned in `nuxt.config.ts` can keep you on legacy Nitro behavior even after the package upgrade.\n- **Deep-mutating `useAsyncData` results in place** is the most common silent breakage from the `shallowRef` change — the data changed, the UI just doesn't notice, which looks like an unrelated rendering bug.\n\n## Best practices\n\n- **Flip `future.compatibilityVersion: 4` before you touch the `nuxt` package version** — the cheapest way to see your own breakage with the smallest blast radius.\n- **Grep for `=== null` \u002F `!== null`** near every `useAsyncData`\u002F`useFetch` call before you upgrade, not after a bug report finds one for you.\n- **Centralize any `useAsyncData` call two components legitimately share** into one composable, so its options can't drift out of sync with the new matching requirement.\n- **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), run `nuxt typecheck` right away and treat what it surfaces as debt you already carried, not a new regression.\n- **Don't move files into `app\u002F` 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.\n\n## FAQ\n\n### Do I have to move my files into the `app\u002F` directory?\n\nNo. Nuxt 4 detects an existing Nuxt 3-style layout and keeps it working without any file moves. Moving to `app\u002F` is optional, and a codemod (`npx codemod@latest nuxt\u002F4\u002Ffile-structure`) does it for you if you choose to.\n\n### Will my app silently return wrong data after upgrading?\n\nOnly if two `useAsyncData`\u002F`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.\n\n### Is it safe to upgrade straight from an old Nuxt 3 version?\n\nYes, 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.\n\n### What happens to Nuxt 3 apps that don't upgrade?\n\nThey 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.\n\n### Does this affect Nuxt modules I depend on, not just my own code?\n\nYes, independently of your own code. Nuxt 2\u002FBridge support is removed from `@nuxt\u002Fkit`, 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.\n\n## Cheat sheet\n\n| Change | Old (Nuxt 3) | New (Nuxt 4) | What to do |\n| --- | --- | --- | --- |\n| App code location | flat `components\u002F`, `pages\u002F`, root `app.vue` | `app\u002Fcomponents\u002F`, `app\u002Fpages\u002F`, `app\u002Fapp.vue` (back-compat if unchanged) | Nothing required; `npx codemod@latest nuxt\u002F4\u002Ffile-structure` if you want to move |\n| Shared `useAsyncData`\u002F`useFetch` key | independent-feeling calls, loosely enforced | `handler`\u002F`deep`\u002F`transform`\u002F`pick`\u002F`getCachedData`\u002F`serialize`\u002F`default`\u002F`middleware` must match | Centralize the call into one composable |\n| `getCachedData` | mainly consulted on initial load | called on every fetch, receives `{ cause }` | Branch on `cause` to skip stale cache on manual refresh |\n| `data`\u002F`error` empty value | `null` | `undefined` | Replace `=== null` \u002F `!== null` checks |\n| `data` reactivity | deep `ref` | `shallowRef` | Reassign `data.value`, don't deep-mutate in place |\n| `public\u002F`\u002F`assets\u002F` server aliases | resolved | removed | Use explicit paths or asset imports |\n| `window.__NUXT__` | present after hydration | removed after hydration | Read it before hydration completes, if you need it |\n| Migration path | — | `future.compatibilityVersion: 4` on Nuxt 3, then bump the package | Fix issues with the dependency unchanged first |\n\n## Key takeaways\n\n- Nuxt 3 reached end-of-life on July 31, 2026 — this migration is no longer optional maintenance, it's closing a real security gap.\n- The `app\u002F` directory default is backwards-compatible and rarely the thing that actually breaks; the data layer and default-value changes are.\n- `useAsyncData`\u002F`useFetch` calls sharing a key now enforce matching options — centralize shared calls into one composable to guarantee it.\n- `data`\u002F`error` default to `undefined` instead of `null`, and `data` is a `shallowRef` — both fail silently, not loudly, so grep for them rather than waiting for a bug report.\n- `future.compatibilityVersion: 4` lets you fix every breaking change while still running the Nuxt 3 package, which is the official, lowest-risk path.\n\nNone 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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-4-migration-breaking-changes\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-4-migration-breaking-changes\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\nWhat's the first thing on this list you'd find if you grepped your own app for it right now?\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Nuxt Hydration Mismatch: Why It Happens and How to Fix It](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-hydration-mismatch)\n- [useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bug](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-useasyncdata-keys-dedupe)\n- [Nuxt 4.5 SSR Streaming: The Route Rules That Disable It](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-ssr-streaming-route-rules)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":108,"canonical":512,"description":109},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-4-migration-breaking-changes","01a11d02-8b28-774b-9f36-fc5c3a98595a",{"name":515,"part":516,"total":516,"items":517},"Nuxt Deep Dive",6,[518,522,527,531,536,540],{"slug":519,"title":520,"publishedAt":521,"readingMinutes":111},"nuxt-weekly-cross-request-state-leak","Nuxt useState vs ref(): Why Server State Leaks Across Users","2026-08-30T11:37:11.335Z",{"slug":523,"title":524,"publishedAt":525,"readingMinutes":526},"nuxt-weekly-useasyncdata-keys-dedupe","useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bug","2026-09-13T11:26:28.014Z",15,{"slug":528,"title":529,"publishedAt":530,"readingMinutes":111},"nuxt-weekly-hydration-mismatch","Nuxt Hydration Mismatch: Why It Happens and How to Fix It","2026-09-20T16:26:05.191Z",{"slug":532,"title":533,"publishedAt":534,"readingMinutes":535},"nuxt-weekly-nitro-server-routes","Nuxt Server Routes Explained: How Nitro Builds Your API","2026-09-29T12:10:53.560Z",13,{"slug":537,"title":538,"publishedAt":539,"readingMinutes":111},"nuxt-weekly-ssr-streaming-route-rules","Nuxt 4.5 SSR Streaming: The Route Rules That Disable It","2026-10-04T12:03:48.007Z",{"slug":46,"title":108,"publishedAt":112,"readingMinutes":111},{"id":542,"locked":18},"01a11d02-8b56-710f-bfa9-2becd34f2523",[544],{"id":45,"slug":46,"title":48,"_count":545},{"questions":51},[547],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":549,"questionCount":51},{"questions":51}]