← blog
NuxtOctober 11, 2026 · 14 min read

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.

Parsa Jiravand · Frontend engineer · building bestpractic
Nuxt 4 Migration: The Breaking Changes That Actually Bite

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.

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/useFetch call that worked fine on Nuxt 3
  • Fix the null → undefined default change before it silently passes a stale if (data.value === null) check
  • Use future.compatibilityVersion: 4 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

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.

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:

TypeScript
1
2
3
4
5
6
7
8
// 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: 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 ignore node_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?

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:

Text
1
2
3
4
5
6
7
8
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 set srcDir in nuxt.config.ts, Nuxt 4 resolves modules/, public/, shared/, and server/ 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.
  • 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.

If you do want to adopt the new layout, Nuxt ships a codemod rather than asking you to move files by hand:

Shell
npx codemod@latest nuxt/4/file-structure

Moving files is optional. Everything else in this article is not.

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:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
// 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:

TypeScript
1
2
3
4
5
6
// 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:

TypeScript
1
2
3
4
5
6
7
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] }, })

Two smaller defaults sit next to the data layer change and are easy to miss in a changelog skim:

  • 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/useFetch result — every one of those is a candidate for the exact bug shown in "The problem" above.
  • 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.

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.

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.

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:

TypeScript
1
2
3
4
5
6
// 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):

Shell
npx codemod@0.18.7 nuxt/4/migration-recipe

  • 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.
  • compatibilityDate needs bumping too. An old date pinned in nuxt.config.ts can keep you on legacy Nitro behavior even after the package upgrade.
  • 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.

  • Flip future.compatibilityVersion: 4 before you touch the nuxt package version — the cheapest way to see your own breakage with the smallest blast radius.
  • Grep for === null / !== null near every useAsyncData/useFetch call before you upgrade, not after a bug report finds one for you.
  • 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.
  • 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.
  • 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.

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.

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.

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.

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.

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.

ChangeOld (Nuxt 3)New (Nuxt 4)What to do
App code locationflat components/, pages/, root app.vueapp/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 keyindependent-feeling calls, loosely enforcedhandler/deep/transform/pick/getCachedData/serialize/default/middleware must matchCentralize the call into one composable
getCachedDatamainly consulted on initial loadcalled on every fetch, receives { cause }Branch on cause to skip stale cache on manual refresh
data/error empty valuenullundefinedReplace === null / !== null checks
data reactivitydeep refshallowRefReassign data.value, don't deep-mutate in place
public//assets/ server aliasesresolvedremovedUse explicit paths or asset imports
window.__NUXT__present after hydrationremoved after hydrationRead it before hydration completes, if you need it
Migration path—future.compatibilityVersion: 4 on Nuxt 3, then bump the packageFix issues with the dependency unchanged first

  • 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/useFetch calls sharing a key now enforce matching options — centralize shared calls into one composable to guarantee it.
  • data/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.
  • future.compatibilityVersion: 4 lets 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.

Runs right in your browser — poke at it and watch the concept react live.

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?


🚀 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:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.