Back to Blogs

What Nuxt 4 Actually Changed (and What to Do About It)

Why Start on 4 Instead of 3

When I rebuilt this portfolio, Nuxt 4 was out. No reason to start on the older version. But almost every tutorial, module readme, and answer I found during development was written for Nuxt 3. The code mostly worked — the parts that didn't were subtle enough to cost real time.

This post covers what actually changed between 3 and 4. Not the press release, but what I ran into while building.


The app/ Directory Is Now the Default

In Nuxt 3, your project has pages, components, and composables at the root:

my-app/
├── pages/
├── components/
├── composables/
├── server/
├── nuxt.config.ts

In Nuxt 4, srcDir defaults to app/. All application-level code moves inside:

my-app/
├── app/
│   ├── pages/
│   ├── components/
│   ├── composables/
├── server/
├── nuxt.config.ts

The server/ directory stays at the root. So does content/ (if you use @nuxt/content) and public/. Only the code you write for the front-end moves.

I set srcDir: 'app/' explicitly in nuxt.config.ts:

export default defineNuxtConfig({
  srcDir: 'app/',
  // ...
})

In Nuxt 4, that line is redundant — it's already the default. But it makes the intent obvious to anyone reading the config, so I kept it.

The confusion hits when you copy code from Nuxt 3 tutorials. They reference ~/plugins or ~/middleware. Those paths still work — ~ resolves relative to srcDir. But when a blog post says "add this to plugins/", it means app/plugins/ in Nuxt 4, not the root. I got this wrong twice with module configuration before it clicked.


compatibilityDate Is Not Optional

Nuxt 4 added a compatibilityDate field in nuxt.config.ts. It controls which default behaviors your app opts into.

export default defineNuxtConfig({
  compatibilityDate: '2025-07-15',
  // ...
})

Without it, Nuxt 4 falls back to Nuxt 3 behavior for anything that changed — you get Nuxt 4's package but Nuxt 3's defaults. With it set to a date, you're opting into all breaking changes that shipped on or before that date.

The idea is gradual migration. Upgrading an existing app? Set the date to when you started upgrading and fix breaking changes one at a time. Starting fresh? Set it to the latest date and get everything.

I set mine to 2025-07-15. The alternative is running Nuxt 4 with Nuxt 3 defaults, which makes the upgrade mostly pointless.


process.client Is Gone

Nuxt 3 checked for the browser environment with:

if (process.client) {
  // browser-only code
}

In Nuxt 4, that's import.meta.client:

if (import.meta.client) {
  // browser-only code
}

Same for the server side: process.server becomes import.meta.server.

The reason: process is a Node.js global. Using it to mean "is this running in the browser?" was always a workaround. Nuxt 4 moved to Vite's import.meta, which is the proper place for build-time environment flags.

The old syntax doesn't throw an error — it returns undefined instead of true or false, so your condition silently evaluates to false. I had a dark mode initialization block that stopped working and spent 20 minutes tracing it before realizing process.client was the culprit.

A search-and-replace handles most of it. For composables that need browser-only initialization, I prefer onMounted over either form — it's more explicit and doesn't depend on environment flags at all. The composable pattern post covers the full approach.


Auto-Imports Still Work, Just From app/

I was worried that Nuxt's auto-import system — which automatically imports composables, components, and Vue APIs without explicit import statements — would behave differently in Nuxt 4.

It doesn't. app/composables/ is auto-imported. app/components/ is auto-imported. The ~ alias resolves to app/. Works the same as Nuxt 3, just from the new source directory.

The edge case: anything outside app/ is not auto-imported. Files in server/api/ don't get client auto-imports, which is expected.

One gotcha worth knowing: if a composable name conflicts with a Nuxt built-in, Nuxt's version wins silently. I had this happen once. The symptom is a composable behaving strangely for no apparent reason. Adding an explicit import statement tells you immediately which file is actually being used.


@nuxt/content v3 Changed More Than Nuxt Did

This isn't a Nuxt 4 change specifically, but upgrading Nuxt usually means upgrading modules. @nuxt/content v3 — the version compatible with Nuxt 4 — dropped several APIs from v2.

The $content() query API is gone. The new API is queryCollection(). The collection structure changed. Frontmatter handling changed. The rendering pipeline changed.

I spent more time on content module changes than on Nuxt itself.

My blog API (server/api/blogs.get.ts) reads the markdown files directly with gray-matter rather than going through the content module's query layer. That approach survived the upgrade without changes. I didn't plan it that way, but it turned out to be the more stable choice for a simple blog listing endpoint.

If you have a content-heavy site, read the @nuxt/content v3 migration separately. It's its own migration, not just a dependency bump.


What Didn't Change

The things I expected to break and didn't:

  • File-based routing — same conventions, same dynamic segments, same catch-all patterns
  • useFetch and useAsyncData — same API, some internal deduplication improvements
  • Layoutsapp/layouts/, same <slot /> pattern
  • Server routesserver/api/, same defineEventHandler, same $fetch
  • Tailwind CSS module — add to modules array, configure in tailwind.config.ts, done
  • GSAP + Lenis integration — no changes, browser APIs are browser APIs regardless of Nuxt version

The actual breaking surface for a new project is small. The app/ directory path, import.meta.client, and compatibilityDate cover 90% of what trips people up.


The Short Version

Starting fresh on Nuxt 4: set srcDir: 'app/' in config (or just leave it as the default), add compatibilityDate set to the latest date, and replace any process.client occurrences with import.meta.client. That's it.

Upgrading from Nuxt 3: the official migration guide has the step-by-step. compatibilityDate lets you do it incrementally — set it to today's date and fix what breaks, rather than everything at once.

For this portfolio, the app/ directory structure makes the project layout cleaner — application code in one place, server code and content in another. It's a better default than everything at the root. The upgrade was small, and the structure improvements compound every time I add a new page or composable.

Enjoyed this?

Share it with your network