Test Nuxt 4 Behavior Without Upgrading the Package
Nuxt 3.12+ ships with a compatibility flag that enables Nuxt 4 behavior inside your existing install. Use that first.
// nuxt.config.ts
export default defineNuxtConfig({
future: {
compatibilityVersion: 4
}
})
With this flag set, you get all the Nuxt 4 behavior changes without touching your package.json. Fix what it surfaces. Then upgrade nuxt to v4. Don't do both at once — you'll be debugging the behavior changes and the package changes at the same time, and they produce similar errors.
I ran the flag on the portfolio, broke four things, fixed them over a weekend, then did the version bump in a separate commit. The version bump was boring. The behavior fixes were the actual work.
The App Directory: Mostly Mechanical, One Sharp Edge
The most visible change: in Nuxt 4, your pages/, components/, composables/, layouts/, and assets/ directories move from the project root into an app/ subdirectory.
Nuxt 3 structure Nuxt 4 structure
───────────────── ──────────────────
├── pages/ ├── app/
├── components/ │ ├── pages/
├── composables/ │ ├── components/
├── layouts/ │ ├── composables/
├── assets/ │ ├── layouts/
└── server/ │ └── assets/
└── server/
server/ stays at the root. Everything browser-facing moves.
On this site that meant relocating about 40 files — components, composables, pages, assets. The actual move took ten minutes. Finding what it broke took longer.
The sharp edge: if you have path aliases in nuxt.config.ts pointing at specific directories, they don't update themselves. I had two aliases pointing at ~/components/ that I missed. The build succeeded, the TypeScript check passed, and two components silently rendered empty because their imports resolved to nothing.
After any file move, search your nuxt.config.ts and any explicit import paths for the old directory names.
Data Fetching: ShallowRef by Default
In Nuxt 4, useAsyncData and useFetch return data as a shallowRef instead of a regular ref.
Shallow refs track only the top-level reference. If you mutate nested properties, nothing re-renders.
// Nuxt 3 — this worked
const { data } = await useFetch('/api/blogs')
data.value[0].title = 'Updated' // triggered reactivity
// Nuxt 4 — this doesn't
const { data } = await useFetch('/api/blogs')
data.value[0].title = 'Updated' // silent, no re-render
data.value = [...data.value] // this triggers reactivity
The blog listing on this site reads data and never mutates it in-place, so this was fine. But there was one place in the PDF tools where I updated a flag on a fetched file object to track processing state. That stopped working immediately and took a moment to trace back to the shallowRef change.
The rule in Nuxt 4: always replace the top-level value instead of mutating nested properties.
// Replace, don't mutate
data.value = data.value.map(file => ({
...file,
processed: true
}))
It's actually cleaner this way. Mutations scattered through components after a fetch are hard to follow. Replacing the whole value makes the data flow explicit.
useFetch Deduplication Bites Once
Nuxt 4 deduplicates concurrent useFetch calls to the same URL by default. Two calls to /api/blogs in the same component tree during the same render produce one request, and both callers get the same response.
Most of the time this is the right behavior. But I had a composable that called useFetch twice against the same endpoint — once on load, and once after a filter changed. The second call was being absorbed into the first because the URL was identical and Nuxt considered them the same request.
The fix is the key option:
const { data, refresh } = await useFetch('/api/blogs', {
key: `blogs-${activeTag.value}`,
query: { tag: activeTag.value }
})
Give each logical fetch its own key and deduplication stops collapsing them. The key should change when the intent changes — not the URL alone.
If you're calling the same endpoint with different intents (initial load vs. filtered reload vs. manual refresh), they each need a distinct key.
Auto-Imports and the Server/Client Split
Nuxt 4 tightened the auto-import scope. In Nuxt 3, composables in the root composables/ directory were resolved across both server and client contexts. In Nuxt 4, app/composables/ is scoped to the client side only.
This broke one server route I had. It was importing a small utility that had lived in composables/ because that was where everything else lived. In Nuxt 3, the server route found it through auto-imports. In Nuxt 4, it couldn't.
The fix is to understand the correct directory for shared code:
├── app/
│ └── composables/ # client-only composables, auto-imported in pages/components
├── server/
│ └── api/ # server routes
└── utils/ # auto-imported in both contexts
Utilities that are used in both server and client code belong in utils/ at the project root. Nuxt auto-imports that directory in both contexts. The app/composables/ directory is the right home for Vue composables that use onMounted, ref, and other client-only APIs — it's not the right home for pure functions that also need to run on the server.
My server route was using a pure string-formatting utility. Moving it to utils/ was the right place for it anyway.
What Didn't Need Touching
The GSAP and Lenis sync works identically. The composable pattern for third-party libraries didn't change. The markdown blog system, the Tailwind config, the PDF tools composables — all fine.
The Nuxt module ecosystem had Nuxt 4 compatible releases ready. @nuxt/content, @nuxtjs/tailwindcss, the image module — all were ahead of the curve by the time I upgraded.
The four things that broke were: the alias paths after the file move, the in-place mutation in the PDF tool, the deduplicated useFetch call, and the server route that was importing from the wrong directory. Total debugging time was under two hours.
The Order That Works
- Add
future: { compatibilityVersion: 4 }tonuxt.config.ts - Run
npm run devand read every error - Fix the behavior issues (shallowRef mutations, deduplication keys, import scope)
- Run your full test suite and do a manual browser pass
- Remove the flag, bump
nuxtto v4 inpackage.json - Run
npm installand verify the build still passes - Do another browser pass
The flag and the version bump are two commits. If step 6 surfaces something new, it's a Nuxt package issue rather than a behavior issue — easier to isolate and search for.
Nuxt 4 is worth the migration. The app directory structure is cleaner, the data fetching defaults are more explicit, and the boundary between server and client code is clearer than it was before.