Migrating to Nuxt 4 - The New app/ Directory, Smarter Data Fetching, and a Painless Upgrade
Nuxt 4 is here. Learn the new app/ directory structure, improved useFetch/useAsyncData, the path-alias gotcha, and how to upgrade safely with codemods.

I'll be honest — I put off this migration for longer than I should have. "It'll probably break something" is the lie I kept telling myself. When I finally sat down and did it, the whole thing took a morning. Nuxt 4 shipped in July 2025 with a clear mandate: make the upgrade painless. Most breaking changes were previewed behind a compatibility flag for over a year, every sharp edge has a config escape hatch, and a codemod handles the mechanical parts. This guide covers everything that actually bit me — and what didn't.
What Changed (The Short Version)
app/directory — application code moves into an isolated subtree; Vite's watcher scope shrinks- Smarter
useAsyncData/useFetch— shared keys deduplicate requests, reactive keys auto-refetch, data cleans up on unmount - TypeScript multi-project — separate TS projects for app / server / shared / builder code; one root
tsconfig.jsonis all you need - Compatibility-first — the classic Nuxt 3 layout still works; nothing breaks on a bare version bump
The New app/ Directory — Why I Actually Like It
In Nuxt 4, your application code lives inside app/. The root is reserved for configuration, server code, and content. No more sifting through node_modules entries and server/ routes just to find a component.
project/
├── app/ ← all application code lives here
│ ├── app.vue
│ ├── app.config.ts
│ ├── error.vue
│ ├── components/
│ ├── composables/
│ ├── layouts/
│ ├── middleware/
│ ├── pages/
│ ├── plugins/
│ └── utils/
├── content/
├── public/
├── server/
├── shared/
├── nuxt.config.ts
└── tsconfig.json
This is exactly the layout this blog runs on — and the headless Nuxt + Shopware stack I use for storefront work follows the same pattern — so I can tell you from experience it holds up in production.
Why it's faster: Vite watches a smaller directory tree. With node_modules, .git, and server/ out of scope, HMR is noticeably snappier on large projects. If you care about development-loop performance the same way you care about Core Web Vitals in production, this is worth the migration.
Moving your files:
mkdir -p app
mv components composables layouts middleware pages plugins utils app/
mv app.vue app.config.ts error.vue app/ 2>/dev/null || true
This migration is optional. Nuxt 4 detects the classic Nuxt 3 layout and keeps working. You choose when — or whether — to move.
The ~/ Alias Gotcha — This Is the One That Got Me
Read this before you do anything else. I wish someone had told me.
Before Nuxt 4: ~/ and @/ resolved to the project root.
After Nuxt 4 with app/: both resolve to app/.
Any file outside app/ — shared types, root-level helpers, config constants — that you import via ~/ will throw a "module not found" error at build time. I had a handful of shared type files sitting at the project root, all imported with ~/types/..., and they all blew up the moment I moved to the new structure. Easy to fix once you know, infuriating to diagnose if you don't.
Fix option 1 — add a root alias in nuxt.config.ts:
import { fileURLToPath } from 'node:url'
export default defineNuxtConfig({
alias: {
'~root': fileURLToPath(new URL('./', import.meta.url)),
},
})
Then update affected imports: ~/types/product → ~root/types/product.
Fix option 2 — move shared code into app/ or shared/:
Anything used only by the app belongs in app/. Anything shared between app and server belongs in shared/ — Nuxt auto-imports from there and the alias change doesn't affect it.
Run a quick search before migrating:
# find imports that cross the app/ boundary
grep -r 'from ~/' app/ | grep -v 'from ~/app/'
Data Fetching Upgrades — Three Things Worth Knowing
useAsyncData and useFetch got three meaningful improvements. In my experience, the shared-key deduplication alone pays back the migration cost on any app with nested layouts.
Shared Keys — no more duplicate requests
Components that call useAsyncData with the same key now share the result. Previously, two components mounted on the same page each triggered their own request even for identical data. I've seen this cause noticeable flicker in product listing pages — now it just works.
Reactive Keys — automatic refetch
Pass a function (or computed) as the key and the composable refetches whenever it changes:
<script setup lang="ts">
const route = useRoute()
const { data: product } = await useAsyncData(
// reactive key — changes when the route param changes
() => `product-${route.params.slug}`,
() => $fetch(`/api/products/${route.params.slug}`)
)
</script>
<template>
<div v-if="product">
<h1>{{ product.name }}</h1>
</div>
</template>
When the user navigates from /products/shirt to /products/jacket, the key changes, the old data is cleaned up, and a fresh fetch fires — without any manual watch. My rule of thumb used to be "add a watcher and call refresh()"; now I just reach for a reactive key.
Automatic cleanup
Data registered via useAsyncData is now cleared when the owning component unmounts. In long-lived SPAs this was a subtle memory leak; Nuxt 4 handles it transparently. I didn't realise how often I was leaking state until I wasn't anymore.
Quick mental model:
$fetch— raw request, runs every time, no cachinguseFetch— smart wrapper; fetches once on server, rehydrates on clientuseAsyncData— full control; reactive key, manualrefresh(), custom transforms
Shopware Frontends leans heavily on useFetch + reactive keys for its catalog pages — the Nuxt 4 upgrade makes that pattern cheaper out of the box.
TypeScript — One Config, Four Projects
Nuxt 4 sets up separate TypeScript projects for each environment: app, server, shared, and the Nuxt builder itself. This gives you:
- Accurate autocompletion — server-only globals like
H3Eventdon't bleed into component files - Correct inference — no more false "cannot find name
useNuxtApp" in server routes - Faster
tsc— each project is smaller and checked in isolation
You only maintain one tsconfig.json at the project root. Nuxt generates the sub-configs in .nuxt/ during nuxi prepare. Nothing to hand-edit. I was slightly worried this would make IDE setup more complex — it didn't. If anything, autocomplete in VS Code got more accurate.
Removed APIs — Check These Before Upgrading
A few things disappeared in Nuxt 4:
| Removed | What to do |
|---|---|
generate.exclude | Use routeRules in nuxt.config.ts to control pre-rendering per route |
generate.routes | Same — routeRules or a nitro.prerender.routes array |
Nuxt 2 compatibility in @nuxt/kit | Module authors: drop legacy defineNuxtModule patterns that targeted Nuxt 2 |
If you maintain a custom Nuxt module, run npx nuxi module check to surface compatibility issues before upgrading the consuming app. I hit the @nuxt/kit one on a client project with a homegrown module — nuxi module check flagged it immediately.
How I Actually Did the Upgrade — 5 Steps
- Bump the package —
bun add nuxt@latest(or npm/pnpm equivalent) - Run the codemod — automates the majority of mechanical changes:
npx codemod@latest nuxt/4/migration-recipe - Move
app/code — run themkdir/mvcommands from the section above, or skip and stay on the Nuxt 3 layout - Fix alias imports — audit
~/references outsideapp/; apply the~rootalias or move files toshared/ - Check removed APIs — search for
generate.exclude,generate.routes, and any Nuxt 2 module patterns; replace per the table above
Honestly, most projects are done after step 2. The app/ move and alias fixes are only relevant if you opt into the new directory structure — and given that the alias thing is what got me, I'd say audit your imports first regardless.
Support window: Nuxt 3 receives support for at least 6 months after Nuxt 4's release date (July 15, 2025). Plan your migration before January 2026; anything after that is on borrowed time.
Worth Doing
Nuxt 4 is a well-engineered major version bump — the compatibility flag approach meant most teams hit zero surprises on release day, and that matched my experience. The app/ directory structure is the biggest conceptual shift, but it's genuinely useful: faster HMR, cleaner project root, and an obvious home for application code. The data fetching improvements pay dividends on any project with nested routes or shared components that hit the same endpoints.
If you're building or migrating a headless Shopware storefront with Nuxt, this is worth doing now rather than carrying the Nuxt 3 layout indefinitely. I help teams navigate exactly these migrations — feel free to reach out if you want a second pair of eyes on your upgrade.
Useful References
- Nuxt 4 Migration Guide — official upgrade docs and compatibility flags
- Nuxt Data Fetching Docs —
useFetch,useAsyncData,$fetchexplained - Nuxt Directory Structure — the new
app/directory reference - codemod CLI — run the
nuxt/4/migration-recipefrom here
Enjoyed this?
Get new posts as they land.
Keep reading

Debugging LCP on a Real Site - A Case Study on marcofaul.de
A first-person walkthrough of fixing LCP on this very site - a JS reveal animation gating first paint, a font preload that held rendering hostage, a 5.8s image CDN cold start, and the measurement noise that almost sent me chasing ghosts.

Nuxt 4.5 - Vite 8, Rspack 2, SSR Streaming, and the Road to Nuxt 5
Nuxt 4.5 is the biggest release in a while - Vite 8, an Rsbuild-powered Rspack 2 builder, experimental SSR streaming, stable error codes, and a pile of groundwork for Nuxt 5. Here's what actually matters, what to test before upgrading, and why the Nuxt 3 clock is about to run out.

Shopware Frontends - Building a Composable Storefront with Vue and Nuxt
Shopware Frontends is the official way to build headless storefronts on Shopware 6. Learn the Vue Starter Template, Nuxt layers, composables, and the Store API architecture.