Frontend

Nuxt 3 is end-of-life: a practical Nuxt 4 migration guide

Nuxt 3 is now end-of-life. Migrate to Nuxt 4 safely with a practical sequence for dependencies, directories, data fetching, TypeScript, tests, and rollout.

Quick answer

Nuxt 3 reached end-of-life on 31 July 2026. It is now outside the Nuxt team’s open-source support window, which means it should not be expected to receive maintenance or security fixes. If you maintain a production Nuxt 3 application, the defensible default is to migrate to the current Nuxt 4 release rather than wait for Nuxt 5.

As of 5 August 2026, the current stable release is Nuxt 4.5.1. For most conventional Nuxt applications, the migration is quite easy. The difficult part is rarely changing the version number. It is finding the application assumptions that Nuxt 3 allowed and Nuxt 4 makes more explicit, particularly around data-fetching keys, shallow reactivity, TypeScript boundaries, custom build configuration, and project structure.

The safest approach is to separate the framework upgrade from architectural cleanup. Upgrade dependencies and restore a green build while keeping the existing directory structure. Move files into the Nuxt 4 app/ structure later, in a separate change. That gives you smaller diffs, clearer failures, and a much easier rollback.

Nuxt 3 is already unsupported

The official Nuxt release roadmap lists 31 July 2026 as the end-of-life date for Nuxt 3.

End-of-life does not mean that your application stops running on 1 August. Your deployed bundle does not know that the support window ended. The practical change is that newly discovered framework vulnerabilities, regressions, ecosystem incompatibilities, and platform changes may no longer be fixed for the Nuxt 3 branch.

That distinction matters because Nuxt released security updates for both Nuxt 4 and Nuxt 3 on 27 July 2026, only four days before Nuxt 3 reached end-of-life. Nuxt 4.5.1 addressed several server-side issues, including authorization bypasses involving route rules, denial-of-service conditions, server-island handling, and cross-user payload disclosure in some cached-page configurations.

The timing is a useful warning. Remaining on an unsupported release is not an abstract maintenance concern. It changes how confidently you can respond to the next security advisory.

A company with contracted extended support and a dated migration plan may make a different short-term decision. Simply having a busy roadmap is not an equivalent control.

Do not wait for Nuxt 5

Nuxt 5 is in development, but Nuxt 4 remains the active stable major version. The Nuxt team has committed to supporting Nuxt 4 for at least six months after the next major release.

Waiting for Nuxt 5 leaves an application on an unsupported version for an unknown period and then asks the team to absorb two major-version boundaries at once. That usually creates more risk, not less.

A better sequence is:

  1. Move the application to a supported Nuxt 4 release.
  2. Stabilize production and remove temporary compatibility settings.
  3. Test Nuxt 5 behavior separately when the application and its dependencies are ready.

Nuxt 4.2 and later can expose selected future Nuxt 5 defaults through future.compatibilityVersion, but enabling that setting is not part of a Nuxt 3 to Nuxt 4 migration. Treat it as another upgrade project. Combining both transitions in one pull request makes failures needlessly difficult to attribute.

For a broader architectural overview, see what changed in Nuxt 4. This guide is specifically about moving an existing production application across the version boundary. If the migration needs hands-on planning or implementation, see my Nuxt and Vue development service.

The migration is easy when you separate it from cleanup

In my experience, the migration itself is usually quite easy. The real work is checking the assumptions around it.

A standard application using pages, layouts, composables, Nitro API routes, ordinary modules, and conventional useFetch calls may require little more than dependency updates, several type fixes, and focused regression testing.

Risk increases when the project contains:

Area Why it needs attention
Custom Vite or Rspack configuration Nuxt 4.5 uses newer build-tool generations with changed plugin behavior
Custom Nuxt modules Build hooks, templates, watcher paths, and layer ordering have changed
Reused useAsyncData keys Calls using the same key now share state and must use compatible options
Mutation of fetched nested data Fetched data is shallowly reactive by default
A custom srcDir Nuxt 4 resolves several root directories differently
Nuxt layers Corrected module order may expose dependencies on the previous behavior
Low-level Unhead usage Nuxt 4.5 includes Unhead 3 and stricter APIs
Heavy prerendering or caching Shared prerender data and recent cache-related security fixes require validation
Direct use of internal Nuxt state window.__NUXT__ is removed after hydration
Weak automated coverage Build success does not prove routing, hydration, authentication, or data refresh behavior

Do not use the number of changed files as your measure of risk. A five-line dependency change can alter application-wide data-fetching behavior. A large directory move can be behaviorally harmless but make the actual regression impossible to find in review.

Use a staged Nuxt 3 to Nuxt 4 migration

The following sequence keeps the changes observable and reversible.

1. Capture a clean Nuxt 3 baseline

Start from a branch with no unrelated feature work.

Run the project’s existing validation commands before changing dependencies:

yarn install
yarn lint
yarn typecheck
yarn test
yarn build
yarn cypress run

The exact scripts depend on the project. The important part is recording which checks already fail.

A pre-existing type error that becomes visible during the migration is not a Nuxt 4 regression. Without a baseline, teams waste time trying to prove which upgrade caused an old problem.

Also record:

  • The deployed Node.js version
  • The resolved Nuxt version
  • The package-manager and lockfile version
  • The deployment preset
  • Enabled Nuxt modules
  • Custom Vite, Nitro, PostCSS, and route-rule configuration
  • Pages using caching, ISR, SWR, or prerendering
  • Known hydration warnings
  • Current bundle and server startup behavior

Do not change Node, Nuxt, every module, the directory layout, lint configuration, and deployment infrastructure in one commit.

2. Bring old Nuxt 3 applications to the final Nuxt 3 line first

A reasonably current Nuxt 3 application can usually move directly to Nuxt 4.

For an application that has missed many Nuxt 3 releases, first upgrade it to the final Nuxt 3 line and resolve its deprecation warnings:

yarn add nuxt@^3.21.10
yarn nuxt prepare
yarn typecheck
yarn build

This is an intermediate diagnostic step, not a supported long-term destination.

It separates problems introduced during the Nuxt 3 release cycle from actual Nuxt 4 changes. That distinction is particularly useful in applications with old modules, custom Nitro behavior, or framework workarounds that have accumulated over several years.

Commit the working Nuxt 3 state before moving to Nuxt 4.

3. Use Node.js 22 or newer

The current Nuxt 4 installation documentation requires Node.js 22 or newer and recommends using an active LTS release.

Confirm both local development and production environments:

node --version
yarn --version

Check every place that selects a Node version:

  • .nvmrc
  • .node-version
  • Dockerfiles
  • CI images
  • GitHub Actions or GitLab CI configuration
  • Hosting-platform settings
  • Buildpacks
  • Local development containers

Updating only the developer workstation is not a migration. The production build and runtime need to use a supported Node version too.

4. Upgrade Nuxt without moving files

Upgrade to the current stable Nuxt 4 line:

yarn add nuxt@^4.5.1
yarn nuxt upgrade --dedupe
yarn nuxt prepare

Then immediately run:

yarn typecheck
yarn build

Nuxt 4 retains backward compatibility with the established Nuxt 3 directory structure. Use that deliberately.

At this stage, do not move pages/, components/, composables/, or other application directories into app/. First prove that the dependency upgrade works with the structure you already understand.

This gives you a clean answer to a useful question:

Does the application still work on Nuxt 4 before we reorganize it?

If the answer is no, the directory move is not involved. That dramatically reduces the search space.

5. Run the migration codemods in a clean commit

The official Nuxt 4 upgrade guide provides a migration recipe. At the time this article was verified, it pinned Codemod CLI 0.18.7 because of an upstream issue:

yarn dlx [email protected] nuxt/4/migration-recipe

Check the current official guide before running it because the temporary version pin can change.

Run codemods only with a clean working tree. Review every change.

A codemod is useful for repeatable syntax transformations. It cannot know:

  • Whether two data-fetching calls intentionally share a cache key
  • Whether nested API data is meant to be mutable
  • Whether a custom module relies on hook ordering
  • Whether a loading state depends on the old meaning of pending
  • Whether a compatibility flag is hiding a bug
  • Whether your deployment cache must be purged

The tool accelerates the migration. It is not the source of truth.

I would initially deselect the file-structure migration, complete the behavioral upgrade, and run that structural transformation later.

6. Move to the app/ directory separately

Nuxt 4’s default structure places frontend application code under app/, while server and cross-context code remain clearly separated:

app/
  assets/
  components/
  composables/
  layouts/
  middleware/
  pages/
  plugins/
  utils/
  app.config.ts
  app.vue
  error.vue
  router.options.ts

content/
layers/
modules/
public/

server/
  api/
  middleware/
  plugins/
  routes/
  utils/

shared/
  types/
  utils/

nuxt.config.ts

The main rules are:

  • Vue application code belongs under app/
  • Nitro server code remains under root-level server/
  • Code safe to use in both contexts belongs under shared/
  • public/, layers/, modules/, and content/ remain at the project root
  • nuxt.config.ts remains at the root

The new layout gives TypeScript and editors a clearer boundary between browser, server, shared, and build-time code. It also prevents file watchers from treating too much of the repository root as application source.

It is still optional for migrated projects. Nuxt can detect the old structure, and retaining it temporarily is a legitimate risk-control measure.

Watch the meaning of ~

With the Nuxt 4 structure, ~ and @ point to app/, because that is the default srcDir.

Use the root aliases when you actually mean the repository root:

import configuration from '~~/configuration'

For shared application and server utilities, prefer the explicit shared alias:

import type { UserSession } from '#shared/types/user-session'

A common migration failure is moving files under app/ while leaving imports that assumed ~ referred to the repository root.

Search aliases after the move and validate what each import is supposed to mean. Do not replace them mechanically.

Custom srcDir applications need more attention

If the application already uses a custom srcDir, Nuxt 4 resolves modules/, public/, shared/, and server/ relative to rootDir rather than that custom source directory.

Review these settings explicitly:

export default defineNuxtConfig({
  srcDir: 'client',
  serverDir: 'server',
  dir: {
    modules: 'modules',
    public: 'public'
  }
})

Only configure paths that differ from Nuxt’s defaults. Copying every default into nuxt.config.ts makes future migrations harder.

Audit every shared data-fetching key

Nuxt 4 reorganized the state used by useAsyncData and useFetch.

Calls with the same explicit key share the same data, error, and status refs. They must also agree on options that define the result’s shape or caching behavior, including:

  • deep
  • transform
  • pick
  • getCachedData
  • default

This is unsafe:

const { data: summaryUsers } = await useAsyncData(
  'users',
  () => $fetch('/api/users'),
  {
    deep: false,
    pick: ['id', 'displayName']
  }
)

const { data: editableUsers } = await useAsyncData(
  'users',
  () => $fetch('/api/users'),
  {
    deep: true
  }
)

Both calls claim that users identifies the same resource, but they request different state behavior and different data shapes.

Use distinct keys when the resource or representation differs:

interface UserSummary {
  id: string
  displayName: string
}

interface EditableUser {
  id: string
  displayName: string
  email: string
  roles: string[]
}

const { data: summaryUsers } = await useAsyncData<UserSummary[]>(
  'users:summary',
  () => $fetch<UserSummary[]>('/api/users', {
    query: {
      view: 'summary'
    }
  }),
  {
    deep: false
  }
)

const { data: editableUsers } = await useAsyncData<EditableUser[]>(
  'users:editable',
  () => $fetch<EditableUser[]>('/api/users', {
    query: {
      view: 'editable'
    }
  }),
  {
    deep: true
  }
)

Better still, centralize deliberately shared requests in a composable. That prevents different components from quietly assigning different semantics to the same key.

Use reactive keys to represent resource identity

Nuxt 4 supports refs, computed values, and getter functions as async-data keys.

A typed composable can make the relationship between a route parameter and its cached data explicit:

import type { MaybeRefOrGetter } from 'vue'
import { toValue } from 'vue'

interface User {
  id: string
  displayName: string
  email: string
}

export function useUser(userId: MaybeRefOrGetter<string>) {
  const key = () => `user:${toValue(userId)}`

  return useAsyncData<User>(
    key,
    () => {
      const resolvedUserId = toValue(userId)

      return $fetch<User>(
        `/api/users/${encodeURIComponent(resolvedUserId)}`
      )
    },
    {
      deep: false
    }
  )
}

When the resolved key changes, Nuxt can treat the result as a different resource rather than overwriting unrelated data under one static key.

The key must include every input that changes the meaning of the response. For a localized, paginated search, that might include the locale, query, page, sort order, and relevant filters.

A key such as products is not sufficient when the handler actually means “products for this category, language, page, and user segment.”

Fetched data is shallowly reactive by default

In Nuxt 4, data returned by useAsyncData, useFetch, and their lazy variants uses a shallowRef by default.

Replacing the top-level value remains reactive:

interface User {
  id: string
  displayName: string
}

const { data: user } = await useFetch<User>('/api/user')

function updateDisplayName(nextDisplayName: string): void {
  if (!user.value) {
    return
  }

  user.value = {
    ...user.value,
    displayName: nextDisplayName
  }
}

Mutating a nested property does not trigger the same deep tracking:

if (user.value) {
  user.value.displayName = 'New name'
}

The second form may change the JavaScript object without causing every consumer to update as expected.

For fetched server state, immutable replacement is usually the cleaner model. It makes state transitions visible and avoids paying for deep proxies across large response objects.

Where nested mutation is intentional, enable deep reactivity for that specific request:

interface EditableForm {
  title: string
  sections: Array<{
    id: string
    label: string
  }>
}

const { data: form } = await useFetch<EditableForm>(
  '/api/editable-form',
  {
    deep: true
  }
)

Do not restore deep reactivity globally just to avoid reviewing a few mutation sites. That hides the migration problem and gives up the performance benefit for every request.

Review undefined, pending, and dedupe assumptions

Several smaller data-fetching changes are individually simple but easy to miss.

Data and errors default to undefined

Code that checks specifically for null may no longer enter the expected state:

if (data.value === null) {
  // This is no longer a reliable initial-state check
}

Prefer a check that represents the actual application state:

if (status.value === 'idle') {
  // The request has not started
}

if (status.value === 'pending') {
  // The request is running
}

if (status.value === 'success' && data.value) {
  // Data is available
}

if (status.value === 'error') {
  // The request failed
}

pending now means a request is pending

With immediate: false, pending remains false until a request starts. It no longer doubles as “the request has never completed.”

Use status when the distinction between idle, pending, success, and error matters.

Boolean dedupe values are gone

Replace the old aliases with explicit values:

await refresh({
  dedupe: 'cancel'
})

or:

await refresh({
  dedupe: 'defer'
})

The strings communicate the behavior. The removed booleans were easy to misread because similar-looking options had opposite implications in different contexts.

Make prerender keys genuinely unique

Nuxt can share async data between prerendered pages. That avoids repeatedly fetching identical navigation, site settings, or CMS data during generation.

The optimization depends on keys being truthful.

This is unsafe on a dynamic page:

const route = useRoute()

const { data } = await useAsyncData(
  'page',
  () => $fetch(`/api/pages/${route.params.slug}`)
)

Every route claims to represent the same page resource.

Include the route-specific identity:

interface PageContent {
  slug: string
  title: string
  body: string
}

const route = useRoute()

const slug = computed(() => {
  const value = route.params.slug

  if (Array.isArray(value)) {
    return value[0] ?? ''
  }

  return value ?? ''
})

const { data } = await useAsyncData<PageContent>(
  () => `page:${slug.value}`,
  () => $fetch<PageContent>(
    `/api/pages/${encodeURIComponent(slug.value)}`
  )
)

This is not merely a prerendering optimization detail. Incorrect shared keys can produce the wrong content for a route, especially when caching is layered across Nuxt, Nitro, and a CDN.

Let stricter TypeScript defaults expose real assumptions

Nuxt 4 enables noUncheckedIndexedAccess by default.

That means indexed access such as items[0] is typed as potentially undefined, even when the array itself exists:

interface NavigationItem {
  label: string
  href: string
}

function getFirstHref(items: NavigationItem[]): string | undefined {
  return items[0]?.href
}

The migration may produce more type errors, but the new type is usually correct. An empty array is valid JavaScript, and indexing it returns undefined.

Do not immediately disable the option across the project. Fix places where the code genuinely assumed that a collection could never be empty. Those assumptions often correspond to real production edge cases.

TypeScript contexts are now more clearly separated

Nuxt 4 generates separate TypeScript configurations for:

  • Vue application code
  • Nitro server code
  • Shared code
  • Node and build-time code
  • Legacy compatibility

Existing projects extending .nuxt/tsconfig.json can continue to work.

Projects that want stricter context separation can opt into TypeScript project references:

{
  "files": [],
  "references": [
    {
      "path": "./.nuxt/tsconfig.app.json"
    },
    {
      "path": "./.nuxt/tsconfig.server.json"
    },
    {
      "path": "./.nuxt/tsconfig.shared.json"
    },
    {
      "path": "./.nuxt/tsconfig.node.json"
    }
  ]
}

Do not combine extends with this project-reference setup. Remove the existing root extends entry when opting in.

Type augmentations must also live in the relevant context:

  • App augmentations under app/
  • Server augmentations under server/
  • Cross-context augmentations under shared/

The migration does not require project references. Treat them as a separate improvement unless context leakage is already causing editor or type-checking problems.

Check Nuxt 4.5’s build-tool changes

A migration performed today is not merely a migration to the original Nuxt 4.0 release.

Nuxt 4.5 includes:

  • Vite 8
  • Rspack 2 through Rsbuild
  • Unhead 3
  • Newer Nitro, Vue, and supporting packages

Most projects consume these changes through Nuxt and need no direct modifications. Projects with low-level integrations need deliberate review.

Review custom Vite plugins

Inspect code using:

  • vite hooks in nuxt.config.ts
  • extendViteConfig
  • vite:extendConfig
  • vite:configResolved
  • Custom Rollup configuration
  • esbuild-specific options
  • Environment-specific plugins
  • Plugins that inspect internal Vite configuration

A normal vite configuration block may continue to work. The warning sign is code that depends on undocumented object shapes or build-tool lifecycle details.

Run both development and production builds. A plugin working in the development server does not prove that the production bundle is correct.

Review Rspack configuration separately

Nuxt 4.5’s Rspack support runs through Rsbuild.

If the application explicitly selects Rspack or configures it directly, compare the project configuration with the current Nuxt documentation. Do not assume that options written for the earlier integration still map to the same layer.

Applications using the default Vite builder do not need to adopt Rspack during this migration.

Changing the framework major version and the build engine in the same release adds little value unless the build-engine change solves a measured problem.

Review low-level head management

Unhead 3 uses stricter types and no longer supports some lower-level patterns, including Promise input.

Prefer Nuxt’s public imports:

import { useHead } from '#imports'

Avoid importing application composables directly from internal or lower-level packages unless the API is intentionally required.

Search for:

@unhead/vue
@unhead/schema
injectHead

Ordinary useHead and useSeoMeta usage is usually straightforward. Custom plugins and direct manipulation deserve tests around rendered metadata, canonical URLs, structured data, and route transitions.

Check removed and corrected behavior

Several migration failures come from APIs that were already discouraged in Nuxt 3.

Replace top-level generate

Use Nitro prerender configuration:

export default defineNuxtConfig({
  nitro: {
    prerender: {
      ignore: [
        '/admin',
        '/private'
      ],
      routes: [
        '/sitemap.xml',
        '/robots.txt'
      ]
    }
  }
})

The old top-level generate configuration was a Nuxt 2 holdover and is no longer available in Nuxt 4.

Stop reading window.__NUXT__

Access the application payload through Nuxt:

const nuxtApp = useNuxtApp()

console.log(nuxtApp.payload)

The global window.__NUXT__ object is removed after hydration. Code depending on it was already coupled to framework internals.

Read page identity from the route

Page properties such as name and path belong on the route object, not duplicated under route metadata:

const route = useRoute()

console.log(route.name)

Search for uses such as:

route.meta.name
route.meta.path

Recheck component-name assumptions

Nuxt’s normalized component names may affect:

  • <KeepAlive> include and exclude lists
  • Vue Test Utils findComponent calls
  • Component-name assertions
  • Debugging tools
  • Code that inspects component names at runtime

A component still rendering correctly does not prove that name-based caching or tests behave the same way.

Layers and modules can expose hidden ordering dependencies

Nuxt 4 corrected module loading order for layers.

Modules from extended layers now load before project modules, allowing the consuming project to have the final priority. This is the intuitive inheritance model, but applications that accidentally depended on the previous order can behave differently.

Review modules that:

  • Mutate the same configuration
  • Register overlapping aliases
  • Extend the same page collection
  • Register hooks that assume another module has already run
  • Generate files consumed by another module
  • Override runtime components or plugins

When a module needs to wait until all modules have loaded, use the documented lifecycle hook intended for that stage rather than relying on array order as an undocumented coordination mechanism.

Module authors should also review:

  • builder:watch, which now receives absolute paths
  • Filesystem-based EJS template compilation
  • Template helper removals
  • Build-time imports
  • Nuxt Kit compatibility declarations
  • Runtime code bundled for the wrong context

Public modules with active maintainers may already support Nuxt 4. Internal modules and abandoned packages require closer attention.

Build success is only the first checkpoint

A successful yarn build proves that Nuxt produced output. It does not prove that the application behaves correctly.

Run tests against the behaviors most affected by the migration.

Rendering and hydration

Check:

  • Server-rendered HTML
  • Hydration warnings
  • Client-only components
  • Suspense and loading fallbacks
  • Layout transitions
  • Error pages
  • Pages with browser-only dependencies
  • Content that differs between server and client

Review both the browser console and server logs.

Routing

Check:

  • Dynamic parameters
  • Optional parameters
  • Nested routes
  • Route middleware
  • Authentication redirects
  • External redirects
  • Named routes
  • <KeepAlive> behavior
  • Direct page loads
  • Browser back and forward navigation

Data fetching

Check:

  • Multiple components using the same key
  • Route changes that alter query parameters
  • Manual refresh
  • Lazy requests
  • immediate: false
  • Error recovery
  • Cached data
  • Nested object updates
  • Prerendered dynamic routes
  • Loading indicators based on pending

Server behavior

Check:

  • API routes
  • Cookie handling
  • Authentication
  • Runtime configuration
  • Proxy behavior
  • File uploads
  • Streaming responses
  • Scheduled jobs
  • Server plugins
  • Deployment-preset output

Head and SEO output

Check rendered HTML for:

  • Title
  • Description
  • Canonical URL
  • robots
  • Open Graph metadata
  • Twitter metadata
  • Alternate-language links
  • Structured data
  • Route-specific head updates

Do not validate this only in the live DOM. Inspect server-rendered HTML too.

Critical user flows

Cypress or another browser suite should cover the paths that would make the release unacceptable if broken:

yarn cypress run

Prioritize login, checkout, form submission, account changes, navigation, localization, and any application-specific revenue or operational flow.

Deploy with a rollback path

A dependency migration deserves an operational plan even when the code diff is small.

Before deployment:

  1. Preserve the previous lockfile and deployment artifact.
  2. Test the production build with production-like environment variables.
  3. Confirm that database or external-service changes are not coupled to the framework release.
  4. Verify the rollback procedure.
  5. Record the expected cache behavior.
  6. Deploy during a period when errors can be observed and acted upon.

Monitor:

  • HTTP 4xx and 5xx rates
  • Server restarts
  • Memory consumption
  • Response latency
  • Hydration errors
  • Client JavaScript errors
  • Authentication failures
  • Cache hit behavior
  • Failed API requests
  • Unexpected redirect loops

If the application uses Nuxt route caching, SWR, ISR, or a CDN, review the Nuxt 4.5.1 security advisory. The Nuxt team specifically advised affected users to purge cached responses after upgrading because vulnerable payloads may remain stored at an edge or CDN even after the application has been patched.

Updating the origin without invalidating exposed cached content is not a complete remediation.

Common Nuxt 4 migration failures

Symptom Likely cause What to check
Nested API data changes but the UI does not update Fetched data is shallowly reactive Replace the top-level value or enable deep: true for that request
Warnings about inconsistent async-data options The same explicit key is used with different options Centralize the request or use distinct keys
A lazy request never runs immediate: false behavior is being assumed incorrectly Trigger execute() or refresh() and inspect status
Loading state appears too early or too late Code treats pending as “not yet loaded” Use status to distinguish idle and pending
Root imports fail after moving files ~ now points to app/ Use ~~ for root code or #shared for shared code
The app builds but a page shows the wrong prerendered content Async-data keys do not include route identity Include parameters, locale, pagination, and filters in the key
Component tests stop finding components Normalized component names changed Update name-based selectors or prefer stable component references
Head metadata fails type checking Low-level Unhead APIs changed Prefer Nuxt imports and remove unsupported Promise input
Module behavior changes in a layered project Corrected module loading order Remove assumptions about the previous order
Type checking reports many indexed-access errors noUncheckedIndexedAccess is enabled Handle empty arrays and missing object keys explicitly
Production differs from development Custom Vite, Nitro, or deployment behavior Test the actual production output and deployment preset
Security upgrade appears complete but stale content remains CDN or route caches were not purged Invalidate affected cached responses

A practical migration checklist

Before changing dependencies

  • Create a dedicated migration branch
  • Record the resolved Nuxt, Node, and package-manager versions
  • Run linting, type checking, tests, build, and Cypress
  • Record existing failures and hydration warnings
  • Inventory Nuxt modules and custom modules
  • Identify custom Vite, Rspack, Nitro, and Unhead code
  • Identify cached, ISR, SWR, and prerendered routes
  • Confirm a rollback path

During the dependency upgrade

  • Use Node.js 22 or newer
  • Upgrade an old application to the final Nuxt 3 line first
  • Commit the working Nuxt 3 baseline
  • Upgrade to the current Nuxt 4 release
  • Keep the existing directory structure initially
  • Run codemods from a clean working tree
  • Review every generated change
  • Run yarn nuxt prepare
  • Fix type errors rather than broadly disabling stricter options
  • Review all explicit async-data keys
  • Review nested mutation of fetched data

Before release

  • Test SSR and hydration
  • Test dynamic routing and middleware
  • Test authentication and cookies
  • Test lazy and manually refreshed data
  • Test prerendered and cached routes
  • Verify rendered SEO metadata
  • Run critical Cypress flows
  • Test the production build
  • Deploy with monitoring
  • Purge affected CDN and route caches
  • Keep the previous artifact available for rollback

After stabilization

  • Move frontend directories under app/
  • Move cross-context utilities into shared/
  • Review root and source aliases
  • Remove temporary compatibility settings
  • Consider TypeScript project references
  • Remove obsolete framework workarounds
  • Test future Nuxt 5 behavior in a separate change

FAQ

Can Nuxt 4 use the old Nuxt 3 directory structure?

Yes. Nuxt 4 provides backward compatibility and can detect the established root-level directory structure used by Nuxt 3 applications.

That makes it reasonable to upgrade the framework first and move into the new app/ structure later. Applications with a custom srcDir should explicitly review how root-level directories are resolved.

Is the Nuxt 4 directory migration required?

No. It is the default for new Nuxt 4 projects and provides better source boundaries, but migrated applications can retain the old layout.

I still recommend adopting the new structure after the application is stable. Do it because the separation improves maintainability and type context, not because a migration checklist says every folder must move immediately.

Is the Nuxt 4 codemod required?

No. It automates known transformations but does not replace the official migration guide, application tests, or code review.

Run it on a clean branch, inspect the diff, and keep behavioral decisions in human hands.

Should I upgrade directly from Nuxt 3 to Nuxt 4?

A current Nuxt 3 application can usually upgrade directly.

For an old application several minor releases behind, first move to the final Nuxt 3 line. Fixing deprecations there gives you a cleaner Nuxt 4 migration and makes regressions easier to classify.

Should I wait for Nuxt 5?

No, not while running unsupported Nuxt 3.

Move to Nuxt 4, stabilize it, and test Nuxt 5 compatibility separately. Waiting combines an unsupported period with a larger future migration.

How difficult is the Nuxt 3 to Nuxt 4 migration?

For a conventional application, it is often straightforward.

Difficulty depends less on the number of pages and more on custom framework integration, outdated modules, data-fetching assumptions, build configuration, and test coverage. A small application with a custom module can be riskier than a large application that stays within public Nuxt APIs.

Final thought

Nuxt 3 reaching end-of-life changes this migration from optional housekeeping into ordinary production maintenance.

Move to Nuxt 4 now, but resist the urge to modernize everything in the same pull request. Upgrade the runtime first. Keep the old structure while you validate behavior. Review data-fetching identity and reactivity carefully. Test the production output, then adopt the cleaner Nuxt 4 structure as a separate change.

The version change is usually the easy part. A controlled migration is about making sure the application’s hidden assumptions change with it.

About the author

Written by Tiago Galvão

Full Stack Developer · Portugal | Switzerland

I've been building for the web since 2001. Full stack development across Vue, Nuxt, Astro, TypeScript, C#, .NET, and PostgreSQL, among others, with a habit of writing down what actually worked and what didn't once the dust settles.

More about me →