Nuxt 4: What Actually Changes
If you work with Vue.js and have not yet looked at Nuxt 4, now is a good time to do so. The release is not a ground-up rewrite, but its structural changes are substantial enough to warrant a thorough overview before embarking on a new project.
What Nuxt Is, in Brief
Nuxt is a meta-framework built on top of Vue.js. It handles routing, server-side rendering (SSR), static site generation (SSG), data fetching, and a great deal more — all out of the box. The premise is straightforward: you write Vue components, Nuxt takes care of the infrastructure around them.
Version 3 introduced Vue 3's Composition API, the Nitro server engine, and an architecture centred on automatic imports. Nuxt 4 refines each of these pillars and introduces a handful of structural decisions that have a meaningful impact on how projects are organised and maintained.
The Central Change: the app/ Directory
In Nuxt 3, a typical project is laid out as follows:
├── components/
├── composables/
├── layouts/
├── middleware/
├── pages/
├── plugins/
├── server/
├── nuxt.config.ts
In Nuxt 4, all application code is consolidated under a dedicated app/ directory:
├── app/
│ ├── components/
│ ├── composables/
│ ├── layouts/
│ ├── middleware/
│ ├── pages/
│ ├── plugins/
│ └── app.vue
├── server/
├── public/
└── nuxt.config.ts
This is not a cosmetic change. The explicit separation between application code (app/) and server-side logic (server/) enforces a cleaner boundary between concerns — one that becomes increasingly valuable as a codebase scales or as more contributors join a project.
This behaviour was already available in Nuxt 3.x via the
future.compatibilityVersion: 4flag innuxt.config.ts. In Nuxt 4, it is the default.
Data Fetching: useAsyncData and useFetch
The data-fetching primitives themselves remain familiar, but Nuxt 4 tightens their behaviour around reactivity and cache key management.
In Nuxt 3, useAsyncData would occasionally fail to re-trigger when a reactive dependency changed — a subtle source of stale data that was difficult to diagnose. In Nuxt 4, internal watch handling is more consistent and predictable:
// Automatically reactive to changes in `route.params.id`
const { data } = await useAsyncData(`product-${route.params.id}`, () =>
$fetch(`/api/products/${route.params.id}`),
)
The governing principle is straightforward: cache keys must be unique and must reflect every dynamic parameter they depend on. A static key paired with dynamic data will produce cache collisions — this was equally true in Nuxt 3, but Nuxt 4 surfaces the problem more clearly, which encourages better habits from the outset.
Nitro and Server Routes
Nuxt 4 continues to ship Nitro as its server runtime. API routes are defined declaratively in server/api/, with file naming encoding both the path and the HTTP method:
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id")
const product = await db.products.findById(id)
if (!product) throw createError({ statusCode: 404 })
return product
})
Nitro compiles these handlers into a self-contained, portable bundle that can be deployed across a range of targets: Node.js, edge runtimes such as Cloudflare Workers or Vercel Edge Functions, or as a fully static build. In Nuxt 4, Nitro's increased maturity is tangible — type inference is sharper, and utility helpers such as getRouterParam, readBody, and getCookie behave more reliably across deployment targets.
Auto-Imported Composables
Any composable placed in app/composables/ is automatically available throughout the application without an explicit import statement:
// app/composables/useApi.ts
export const useApi = () => {
const config = useRuntimeConfig()
return $fetch.create({ baseURL: config.public.apiBase })
}
// Available directly in any component or page:
const api = useApi()
const data = await api("/products")
This is a genuine productivity gain, though it comes with a caveat: on larger projects, liberal use of auto-imports can obscure where functionality originates, making code harder to navigate for developers unfamiliar with the codebase. A practical convention — one composable per file, named with precision — goes a long way toward mitigating this.
Nuxt Content v3
For projects that use Nuxt as a content platform — blogs, documentation sites, knowledge bases — Nuxt Content v3 is fully compatible with Nuxt 4 and represents a significant step forward. MDC parsing (Markdown enriched with inline Vue components) is noticeably faster, and the content query API is now fully typed:
const { data } = await useAsyncData("articles", () =>
queryCollection("blog")
.where("published", "=", true)
.order("date", "DESC")
.all(),
)
Configuration is handled in nuxt.config.ts:
export default defineNuxtConfig({
modules: ["@nuxt/content"],
content: {
build: {
markdown: {
highlight: { theme: "github-dark" },
},
},
},
})
Key Takeaways
Nuxt 4 is best understood as a consolidation release: it sharpens what Nuxt 3 introduced rather than replacing it. The app/ directory enforces better project organisation, reactivity in data fetching is more dependable, and Nitro has matured into a genuinely robust server runtime. For any new Vue.js project requiring SSR, it is the most defensible starting point available today.
Migration from Nuxt 3 is designed to be incremental. The compatibilityVersion: 4 flag allows teams to adopt new behaviours selectively, without a disruptive big-bang migration. In practice — on my own portfolio project — the transition amounted to an afternoon of work, the bulk of which involved reorganising files into the app/ directory and updating a handful of imports.