Back FoxCode

Migrating Nuxt apps

Nuxt 2 to 3, 3 to 4, Content 2 to 3 migrations

·15 min read

Vue 2 reached end of life on December 31, 2023. Nuxt 2 followed on June 30, 2024. Every app that stayed behind now runs on a framework that receives no security patches, how bad is the jump?

I have made that jump on two large production apps - Nuxt 2 to 3, then 3 to 4 (5 incoming), plus Nuxt Content 2 to 3 along the way. The changelogs cover maybe a third of what the work involves. This post is the other two thirds: the parts you learn by breaking a live system and fixing it before user notices.

The audit comes first

Before I write an estimate, I build a spreadsheet/docs: every dependency in package.json. Does a Vue 3 compatible version exist? Is it maintained? If neither, what replaces it?

On an app that shipped in 2019 and grew for four years it might be crucial. The application code migrates with mechanical, well-documented fixes. The module that has authenticated your users since launch might have no successor at all.

A sample from real audits:

Nuxt 2 dependencyStatus after Vue 3What replaced it
@nuxtjs/auth-nextnever shipped Nuxt 3 supportnuxt-auth-utils / custom sessions on Nitro
@nuxtjs/axiosdiscontinuedbuilt-in $fetch (ofetch)
Vuexmaintenance modePinia
bootstrap-vueVue 2 onlybootstrap-vue-next, a community rewrite
@nuxtjs/proxygoneNitro devProxy / routeRules

The auth row deserves its own paragraph. @nuxtjs/auth was the standard way to handle login in Nuxt 2, and its maintainers never released a Nuxt 3 version. Replacing auth can cost more than migrating all pages and components combined. Nothing in the framework's migration guide warns you about this, because it is not the framework's problem. It is yours.

Apps with similar page counts have differed by 3x in migration cost purely because of what sat in their dependency lists.

Nuxt 2 to Nuxt 3: a rewrite wearing a version number

Calling this a "migration" undersells it. Four layers change at once:

The Vue layer. Vue 3 removed $on, $off and $once, which kills the event bus pattern (this.$root.$emit(...)) that old codebases use everywhere. It removed filters. It changed the v-model contract from value/input to modelValue/update:modelValue, which breaks every custom form component you own. The Vue 3 migration guide lists all of it, and grepping your codebase against that list is day one of any serious estimate.

The build layer. Webpack 4 gives way to Vite. PostCSS 7 to 8. node-sass to sass. Each swap is fine in isolation; together they invalidate most of the build customization an old project accumulated in nuxt.config.js.

The server layer. serverMiddleware becomes Nitro's server/api and server/middleware directories, with h3 instead of Express-style handlers. If your Nuxt 2 app embedded an Express router, that code gets rewritten, and this is where I always find undocumented behavior someone relied on.

The data layer. asyncData(ctx) and the fetch() hook become useAsyncData and useFetch:

export default {
  async asyncData({ $axios, params, error }) {
    try {
      const product = await $axios.$get(`/api/products/${params.id}`)
      return { product }
    } catch (e) {
      error({ statusCode: 404 })
    }
  },
}

useFetch deduplicates requests by key, caches across client-side navigation, and runs on both server and client, while asyncData in Nuxt 2 re-ran on every route change. An app that counted on refetching per navigation quietly serves stale data after the migration.

What about Nuxt Bridge?

Bridge backports Nitro and the Composition API to Nuxt 2, promising a halfway house. My verdict after trying it: good for buying a quarter of time when the team must keep shipping, bad as a place to live. On module-heavy apps, Bridge produced its own category of compatibility bugs on top of the ones I was there to fix.

The alternative that worked on the larger of the two apps: run Nuxt 2 and Nuxt 3 side by side behind a reverse proxy, and move route groups from one to the other over several weeks. Shared cookie domain keeps the session alive across both. Users never saw the seam, and every slice shipped to production the week it was finished instead of waiting for a big-bang cutover.

Migrate, don't improve

Expensive mistake available during a migration is fixing things along the way. The store is ugly, so you restructure it while porting it to Pinia. The API layer is inconsistent, so you clean it up while swapping $axios for $fetch. Three weeks later something is broken and you cannot tell whether the migration broke it or the improvement did.

Moving apartments works the same way. Moving day is for carrying the couch. Reupholstering it happens later, in the new place, once you know nothing got lost in transit. Every commit during a migration should be a translation with an obvious Nuxt 2 counterpart, and the refactor wishlist goes into a file for the month after cutover.

The second expensive mistake is the long-lived migration branch. A branch that diverges from main for three months while the team ships features is a merge conflict generator, and the conflicts land in exactly the files the migration touched most. We have few options here: proxy split, scope freezes, or the whole team switches to a feature branch - no changes on the 'old' one, and we knock out the migration fast.

What happens to the tests

Two suites, two very different bills.

The unit tests were written against @vue/test-utils v1, which supports Vue 2 only. v2 is a rewrite for Vue 3 and it moves the surface every test file touches: createLocalVue is gone (Vue 3 has no global constructor to clone), mocks and stubs live under a global key, propsData became props, find() no longer returns components so component lookups go through findComponent, and synchronous rendering was removed, so every assertion after a state change needs an await.

const localVue = createLocalVue()
localVue.use(Vuex)

const wrapper = mount(Button, {
  localVue,
  store,
  propsData: { label: 'Buy' },
  mocks: { $t: (key) => key },
})

wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()

The runner moves too. Jest plus vue-jest gives way to Vitest, which shares the Vite config the app already has, and @nuxt/test-utils wires the Nuxt environment on top of it.

So the tests were not thrown away, they were rewritten - roughly a mechanical pass per file, in the same commit as the component they cover. The ones that did get deleted were deleted on merit: assertions about internal state and mocked-Vuex plumbing that no longer described anything real after the store moved to Pinia. Budget the unit suite as part of the component port rather than as its own line item, otherwise it becomes the thing that gets cut when the schedule slips.

@vue/compat is the option worth naming here. Alias vue to the migration build, get Vue 3 running with Vue 2 semantics behind per-feature flags, and the old suite keeps passing while you switch flags off one at a time. On a plain Vue 2 SPA that is a good deal. It fit badly on these apps: compat freezes one of the four layers listed above while the build, server and data layers move anyway, and it keeps the tests green against semantics you have already committed to deleting, so you pay for the same migration twice - once to reach compat, once to leave it. On an incremental port where the unit suite is the only safety net, that second bill can be worth paying. With end-to-end coverage in place, it was not.

Which is the other half of the story. The Playwright suite carried across untouched. It tests URLs and clicks, knows nothing about the framework, and it caught the regressions that mattered: a redirect loop on expired sessions, a checkout button dead only after client-side navigation, a hydration mismatch that unit tests structurally cannot see. Unit tests are not worthless, they are just priced in the framework you wrote them for.

If the app has no end-to-end tests, write them before you touch the framework. A week of Playwright smoke tests covering the top user journeys is the highest-ROI week of the entire migration.

Nuxt 3 to Nuxt 4: how a major should feel

Nuxt 3.12 shipped a flag that let you run next year's behavior on this year's framework:

nuxt.config.ts
export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4,
  },
})

I flipped that flag months before the 4.0 release, fixed what broke at my own pace, and the eventual upgrade was a version bump on an app already running v4 semantics. The team also shipped codemods for the mechanical parts, like the move to the new app/ source directory.

In Nuxt 4, useAsyncData and useFetch return shallowRef data by default. Deep mutations like data.value.items[3].qty++ no longer trigger reactivity. Code that mutated fetched data in place breaks silently: no error, no warning, the UI stops updating. Pass { deep: true } per call where you need the old behavior, and treat each of those spots as a refactor candidate.

Silent behavioral changes are the worst class of migration bug.

Nuxt Content 2 to 3: the engine swap

Module migrations follow the same physics as framework migrations, and Nuxt Content 3 is a clean example because it replaced the entire storage model. Content 2 walked your markdown files at runtime. Content 3 parses everything into SQLite at build time and queries it through typed collections. queryContent is gone, along with <ContentDoc>, <ContentList> and document-driven mode. <ContentRenderer> survives.

This migration cost me more than any framework bump, and the reason fits in one config line.

The removal: document-driven mode

One of the two apps was built end to end on Content 2's document-driven mode. You set documentDriven: true and the module took over routing: every markdown file became a page, useContent() exposed the current document anywhere in the app, and a layout key in the frontmatter picked the Nuxt layout for that page. The whole site was markdown files plus a handful of layouts. That was the point of choosing it.

Content 3 removed the mode with no direct replacement. The migration guide offers a catch-all page instead:

pages/[...slug].vue
<script setup>
const route = useRoute()
const { data: page } = await useAsyncData(route.path, () => {
  return queryCollection('content').path(route.path).first()
})
</script>

<template>
  <ContentRenderer v-if="page" :value="page" />
</template>

This covers about 80% of what document-driven did, and the missing 20% was layouts. definePageMeta({ layout }) is resolved at compile time, so it cannot use a value that arrives from an async query. The first workaround you find is setPageLayout(page.value.layout) after the fetch; it applied the layout late or not at all on client-side navigation. The second is opting the page out of layouts and rendering <NuxtLayout> by hand:

pages/[...slug].vue
<script setup>
definePageMeta({ layout: false })
</script>

<template>
  <NuxtLayout :name="page?.layout ?? 'default'">
    <ContentRenderer v-if="page" :value="page" />
  </NuxtLayout>
</template>

It looks like the answer, and it held up until it met real builds: on some routes the layout rendered twice, and nuxt generate baked pages with the wrong layout into the static output. I burned days trying to recreate a feature the module no longer wanted to support.

The fix was to stop recreating it. I dropped the idea of markdown files choosing their own layout and moved the structure into pages/: everything under /blog/** renders through the blog page and its layout, docs through the docs page, and the catch-all handles only what is left. This is not what the site had before. It is better tho. A URL prefix now guarantees a layout, instead of any markdown file being able to pick one for itself, so /blog/whatever cannot ship with the docs chrome because someone typo'd a frontmatter key.

One more trap for anyone keeping frontmatter-driven anything: custom fields like layout have to be declared in the collection schema, or Content 3 moves them into the document's meta object (more on that below). page.layout reads undefined, every page falls back to the default layout, and nothing errors anywhere.

The dependency audit catches dead packages; it does not catch dead modes. Document-driven was one line in a config file, and that one line carried the routing model of the entire site. Grep your config for every option the new major's changelog marks as removed, and treat each hit as a project. And when recreating the old behavior turns into a fight with the framework, ask whether the behavior deserves to survive.

The rest of the migration was translation work. Queries map over directly:

const posts = await queryContent('/blog')
  .where({ draft: { $ne: true } })
  .sort({ date: -1 })
  .find()

The structural change is that collections now demand a schema:

content.config.ts
import { defineCollection, defineContentConfig, z } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    blog: defineCollection({
      type: 'page',
      source: 'blog/*.md',
      schema: z.object({
        date: z.string(),
        tags: z.array(z.string()),
        draft: z.boolean().default(false),
      }),
    }),
  },
})

Content 3 sweeps every undeclared frontmatter field into a meta object on the document. post.customButton from Content 2 becomes post.meta.customButton after the upgrade, every template that reads the old path gets undefined.

On my app this collided with years of organic frontmatter growth. Authors had asked for one-off fields over time - a customButton on some posts, extra flags on others - so a field existed in some files and not in the rest, with no record of which or why. After the migration those fields lived under .meta, but only where they existed at all. My first attempt was defensive access everywhere, post.customButton ?? post.meta?.customButton, which worked and turned every template into archaeology.

Fix: unify the frontmatter. Every field authors are allowed to use got declared in the schema, optional with a default where that makes sense. Every file got normalized to match. The one-off fields nobody remembered asking for got deleted instead of migrated.

The schema surfaced every inconsistency that years of "can we add a field for this one post" requests had left behind.

The future: use the AI

Strip a migration down to its mechanics and it looks like this: breaking-changes documentation on one screen, your code on the other, and you translating old pattern to new pattern, file after file, for weeks. Work with a written spec on the left and a mechanical transformation on the right is the most automatable work in this profession.

An LLM with a good source handles the translation layer of everything above: event bus to provide/inject, value/input to modelValue/update:modelValue, asyncData blocks to useFetch, queryContent chains to queryCollection. The load-bearing phrase is with a good source. A model trained on a decade of Nuxt 2 code will reach for Nuxt 2 idioms the moment it improvises, so pin it to the actual migration guide - paste the relevant section into context, or point it at nuxt.com/llms.txt, the machine-readable index Nuxt publishes for this purpose - and feed it one file at a time.

  • "Migrate, don't improve" goes in the prompt. Models love "improving". Left unconstrained, one will restructure your store while renaming v-model props, and you inherit the exact debugging session that rule exists to prevent. Tell it to translate and nothing else.
  • The E2E suite matters. AI raises how much change you produce per day, so the net that catches behavioral regressions has to carry more weight. The model writes the code; Playwright and your own review decide whether the code tells the truth.

The playbook

What I do now, in order, on any framework migration of a live system:

Audit dependencies before estimating

Every dependency, one row, replacement plan for each dead one. The estimate comes from this table.

Write end-to-end smoke tests

Cover the top user journeys with Playwright before touching the framework.

Freeze scope: translate, don't refactor

Every commit maps to an obvious old-version counterpart. Improvements go in a wishlist file for after cutover.

Ship the smallest slices you can

Compatibility flags, a reverse-proxy split by route group, module-by-module. A slice in production this week beats a branch that is "90% done" for a month.

Cut over behind a rehearsed rollback

Practice the rollback before you need it. If rolling back requires thinking, it is not a rollback plan.

Watch production for two weeks

404 rates, error rates, Core Web Vitals. Hydration and caching bugs surface under real traffic and real navigation patterns, and nowhere else.

Migrations reward boring virtues: audits, tests, small steps, rehearsed rollbacks.

Continue Reading