# Nuxt 2 to Nuxt 3 migration: the steps that worked for me

If you are moving a large Nuxt 2 app into Nuxt 3, this is the guide I wish my past self had. I wrote it during our migration of **Pensionfriend** ([pensionfriend.de](https://pensionfriend.de/en)) from Nuxt 2/Vue 2 to Nuxt 3/Vue 3, after Vue 2 reached end of life. The goal was simple: make the platform **future-proof** in terms of **security**, **performance**, and **modern tooling**, without breaking what our users already rely on.

Over the last two years, I have migrated two production codebases. Before that, I worked on two TypeScript/Nuxt/GraphQL products, both legacy V2 projects. I also hold a [senior Vue.js certification](https://certificates.dev/vuejs/certificates/9cc23661-2ce2-4b0e-8f53-3c794321e019), which formalised what I learned over four years in production. This post is what I learned the hard way: what broke, what finally worked, and the small steps that kept me sane.

The breakthrough was abandoning a line-by-line port. I started with a clean Nuxt 3 app and migrated in slices: **routing**, **i18n**, **data fetching**, **build**, and **deployment behaviour**. Once the foundations were solid, everything else followed with less pain.

---

## What we optimised for

### Goals

* Keep **URL and SEO behaviour** stable (no surprise 404s, no broken pages)
    
* Keep **i18n routing** consistent (**English and German parity**)
    
* Preserve our **static and prerender** setup (we rely on it)
    
* Reduce risk by moving off **Vue 2 end of life**
    
* Make the app easier to evolve with modern tooling (**Vite**, **Nitro**, **composables**, better TypeScript ergonomics)
    

For SEO parity, we treated route parity plus meta parity as a checklist: **same URLs**, **same canonicals**, **same hreflang behaviour**, and the same title and description logic. If prerender failed for a route, the build should not ship.

### Non goals

* No redesign during migration
    
* No rewrite everything into **Composition API** on day one (Options API stayed where it worked)
    
* No perfect cleanup pass up front (we kept the site buildable, then improved things as we touched them)
    

---

## Breaking changes you will hit

* `Vite` and `Nitro` replace `Webpack` and the old server
    
* `asyncData` becomes `useAsyncData`
    
* `$axios` moves to `$fetch`
    
* Vuex often gives way to Pinia
    
* Sass `@import` moves to `@use` (eventually)
    
* Mixins are better replaced with composables
    
* `head` becomes `useHead` and `useSeoMeta`
    
* Plugins change from context injection to `defineNuxtPlugin`
    
* Middleware is **file-based**
    
* Component libraries and SSR edges show up quickly (Vuetify, i18n, analytics scripts, and so on)
    

---

## 1) Start with a fresh Nuxt 3 app, but wire production behaviour early

For Pensionfriend, the non-negotiables were:

* **SSR enabled**
    
* **Static output** (`nitro.preset = "static"`)
    
* **Prerendering** and **crawling links**
    
* Prerendering a known list of **dynamic routes**, including locale variants
    
* **Failing fast** when prerender breaks (broken static pages are silent disasters)
    

Here is the shape of the Nuxt 3 config that mattered most. I am keeping the routes generic here. Swap them for your own.

```plaintext
// nuxt.config.ts
import { defineNuxtConfig } from "nuxt/config";
import vuetify from "vite-plugin-vuetify";
import { EXCLUDE_ROUTE_LIST } from "./routes/exclude-routes";
import routeDeclarations from "./routes/route-declarations";

const SHOULD_PRERENDER = process.env.NUXT_PRERENDER !== "false";

const { default: dynamicRoutesFn } = await import("./routes/dynamic-routes");

// Build a list of routes to prerender (dynamic plus a few parent pages)
const urlMaps = [
  ...(await dynamicRoutesFn()),
  { route: "/en/<section>" },
  { route: "/de/<section>" },
];

const PRERENDER_ROUTES = urlMaps.map((u) => u.route);

export default defineNuxtConfig({
  ssr: true,

  routeRules: {
    "/**": { prerender: SHOULD_PRERENDER },
  },

  build: {
    transpile: ["vuetify", "vue-i18n", "@intlify/core-base", "@intlify/shared"],
  },

  vite: {
    plugins: [vuetify({ autoImport: true })],
    ssr: {
      noExternal: [
        "vuetify",
        "vue-i18n",
        "@intlify/core-base",
        "@intlify/shared",
      ],
    },
  },

  nitro: {
    preset: "static",
    prerender: {
      crawlLinks: true,
      ignore: EXCLUDE_ROUTE_LIST,
      routes: SHOULD_PRERENDER ? PRERENDER_ROUTES : [],
      concurrency: 1,
    },

    // Safety rail: fail the build if any prerender route fails
    hooks: {
      "prerender:route": (route) => {
        if (route.error) {
          console.error(`Prerender failed for ${route.route}`, route.error);
          process.exit(1);
        }
      },
      "prerender:done": ({ failedRoutes }) => {
        if (failedRoutes.length > 0) {
          console.error("Failed routes:", failedRoutes);
          process.exit(1);
        }
      },
    },
  },

  i18n: {
    locales: [{ code: "en" }, { code: "de" }],
    defaultLocale: "en",
    strategy: "prefix",
    customRoutes: "config",
    pages: routeDeclarations(),
  },
});
```

We used `crawlLinks: true`, but we still kept an explicit prerender route list. Crawling does not reliably cover flows that are not linked from public pages, or routes that are locale-mapped and custom-routed. I did not want "it happened to be crawled" to decide what ships.

Folder shape I stuck with:

```plaintext
src/
  assets/
  components/
  composables/
  layouts/
  middleware/
  pages/
  plugins/
  server/api/   # optional, for server only secrets
  sdk/graphql
  stores/
  app.vue
  nuxt.config.ts
```

---

## 2) Pages first: reproduce your data fetch and routes

I still recommend **pages first**, but on Pensionfriend, it came with two constraints:

* We needed **prerender parity**, including locale routes
    
* We needed **URL stability** because we use **custom i18n routes**
    

### Replace `asyncData` with `useAsyncData`

```plaintext
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug as string

const { data: post, pending, error } = await useAsyncData(
  `post:${slug}`,
  () => $fetch(`/api/blog/${slug}`)
)

useSeoMeta({
  title: post.value?.title ?? 'Blog',
  description: post.value?.excerpt ?? ''
})
</script>

<template>
  <main class="container">
    <h1 v-if="post">{{ post.title }}</h1>
    <p v-else-if="pending">Loading…</p>
    <p v-else-if="error">Could not load this article.</p>
    <article v-else v-html="post.body" />
  </main>
</template>
```

If you need API keys on the server, use a Nitro server route and keep secrets out of the browser.

---

## Prerender dynamic routes: the way we ended up doing it

In Nuxt 2, we had `generate.routes`. In Nuxt 3, we ended up with a routes folder pattern:

* `dynamic-routes.ts` aggregates multiple generators
    
* smaller scripts like `generate-appointment-routes.ts`, `generate-blog-articles-routes.ts`, and so on
    
* each generator returns `{ route, payload? }` so the route list is reusable and easy to review
    

Example (simplified from our appointment generator):

```plaintext
// routes/route-scripts/generate-appointment-routes.ts
export type UrlMap = { route: string; payload?: Record<string, any> };

export async function generateAppointmentRoutes(): Promise<UrlMap[]> {
  const appointmentTypes = ["pension-check", "consultation"]; // example

  const langs = [
    {
      code: "en",
      section: "appointment",
      confirm: "confirm",
      confirmation: "confirmation",
    },
    {
      code: "de",
      section: "termin",
      confirm: "bestaetigung",
      confirmation: "bestaetigung-erfolgreich",
    },
  ] as const;

  const urlMap: UrlMap[] = [];

  appointmentTypes.forEach((type) => {
    langs.forEach(({ code, section, confirm, confirmation }) => {
      const base = `/${code}/${section}/${type}`;
      urlMap.push({ route: base });
      urlMap.push({ route: `${base}/${confirm}` });
      urlMap.push({ route: `${base}/${confirmation}` });
    });
  });

  return urlMap;
}
```

Then `routes/dynamic-routes.ts` collects them:

```plaintext
// routes/dynamic-routes.ts
import { generateAppointmentRoutes } from "./route-scripts/generate-appointment-routes";

export default async function dynamicRoutes() {
  const appointment = await generateAppointmentRoutes();
  return [...appointment];
}
```

It is not fancy, but it made routes predictable and reviewable, and it made prerender failures easier to debug.

---

## 3) Layouts, components, and moving away from mixins

### Components: keep Options API, convert gradually

Vue 3 still supports **Options API**, so I did not convert everything. I converted what I touched.

### Mixins to composables, as you touch them

We leaned heavily into composables by feature area (appointment, calculators, lead, and so on). That let us migrate logic gradually without trying to rewrite the whole app architecture at once.

```plaintext
// composables/appointment/useBookedAppointment.ts
export function useBookedAppointment() {
  const booked = ref(false)
  async function book() {
    booked.value = true
  }
  return { booked, book }
}
```

---

## 4) i18n on Nuxt 3: custom routes

For Pensionfriend, we did not just translate strings. We translated **routes**.

Instead of relying purely on file-based routing, we used `customRoutes: "config"` and explicitly mapped route names to locale paths:

```plaintext
// routes/route-declarations.ts
export default function routeDeclarations() {
  return {
    about: { en: "/about", de: "/ueber-uns" },
    "appointment-slug": { en: "/appointment/[slug]", de: "/termin/[slug]" },
    "calculators-index": { en: "/calculators", de: "/altersvorsorgerechner" },
    blog: { en: "/blog", de: "/ratgeber" },
  } as const;
}
```

That was one of the biggest parity wins. The URL strategy was not just a preference; it was part of the product.

One gotcha that still applies: do not register multiple i18n plugins, otherwise you will see:  
**Cannot redefine property: $switchLocalePath**

---

## 5) Store: Vuex to Pinia, or stay on Vuex 4 temporarily

We kept Vuex alive while we migrated, and moved new state into composables or Pinia patterns as we touched features.

The rule that kept things simple:

* **New work** goes to composables or Pinia (if necessary)
    
* **Legacy store logic** can stay Vuex until you have time
    

---

## 6) UI: Vuetify 2 to Vuetify 3, and removing a component submodule

Vuetify 2 to 3 is already a migration on its own. On top of that, we had another blocker.

### The shared component library submodule problem

In Nuxt 2, we used a shared **component library** as a submodule. Over time, it became hard to maintain. When we started the Nuxt 3 migration, we realised:

* **Keeping the submodule meant upgrading it to Vue 3**
    
* **Other systems still depended on it in Vue 2**
    
* **A proper upgrade risked breaking those other systems**
    

So we made the pragmatic call:

* **removed the submodule**
    
* **ported only the components and dependencies we needed into the Nuxt 3 project**
    
* **migrated incrementally as we converted screens**
    

If I could redo one thing, I would plan that dependency earlier. Dual build, separate migration project, or budget for vendoring it in from day one.

---

## 7) Vite specifics

A couple of patterns that helped:

* be explicit about SSR troublemakers (for example `ssr.noExternal` and sometimes `optimizeDeps.include`)
    
* Keep aliases tidy, especially in legacy setups
    
* If you disable prefetch or preload in the build manifest, leave a short comment explaining why
    

---

## 8) Prerender and SEO parity, the part that kept us safe

Because we were static and prerender heavy, we treated prerender as a first-class test.

What helped most:

* `crawlLinks: true` for coverage
    
* `ignore` for known routes that should never be prerendered
    
* failing fast on broken prerender routes using Nitro hooks
    

If you are migrating an SEO sensitive site, this is the difference between "works locally" and "safe to ship".

---

## 9) Sass gotchas: `@import` to `@use`, and the bridge period

Yes, `@use` is the direction. In a large legacy codebase, I kept `@import` for a while, so styling refactors would not block shipping, then converted gradually.

Example:

```plaintext
// options.vite.ts
css: {
  preprocessorOptions: {
    scss: {
      additionalData: `@import "~/assets/styles/variables";`,
      silenceDeprecations: ["import", "global-builtin", "legacy-js-api"],
      quietDeps: true,
    },
  },
},
```

---

## 10) Testing: Jest to Vitest, while adding tests during the migration

We did not stop to convert the whole test suite. We moved to Vitest and added tests on the go, keeping the suite running throughout the migration.

```plaintext
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./test/setup.ts'],
    css: true,
  },
})
```

```plaintext
// test/setup.ts
import { expect } from 'vitest'
import matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
```

---

## 11) A realistic, low-stress migration plan

This is the part most posts skip:

* Nuxt 2 stayed in production while Nuxt 3 matured
    
* We built Nuxt 3 in parallel and ran QA properly
    
* Once Nuxt 3 was ready, we switched the deployment target and cut over
    

The cutover was uneventful because the work was not "Nuxt 3 vs Nuxt 2". It was "Nuxt 3 behaves like Nuxt 2 for users", with prerender and tests acting as the guardrails.

---

## Common gotchas I hit (and fixes)

* **Cannot redefine property: $switchLocalePath**: multiple i18n plugins registered
    
* 404s after static build: missing routes in the prerender list, especially locale variants
    
* Prerender flakiness: reduce concurrency, add ignore list, fail fast on route errors
    
* Vuetify styles missing: `build.transpile` plus `ssr.noExternal` plus include `vuetify/styles`
    
* Shared component library becomes a blocker: decide early, dual build, migrate the library, or vendor it
    
* SCSS migration becomes a time sink: keep `@import` temporarily, convert to `@use` gradually
    

---

## What improved after the migration

* A **future-proof** foundation (Vue 3 plus Nuxt 3 ecosystem) with less legacy drag
    
* Cleaner separation of server-only concerns via Nitro server routes
    
* Faster iteration loop with Vite, especially TypeScript and styles
    
* More confidence in shipping changes because prerender and tests acted like guardrails
    

---

## Final thought

You do not have to win the migration in a week or a month. Everything starts from the Nuxt 3 config. Set up a clean Nuxt 3 skeleton, get the foundations right, and make Nuxt 3 behave like Nuxt 2 where users notice. Keep the same URLs, i18n paths, page titles, scroll behaviour, and visible interactions. Migrate one static page, then one dynamic page. After that, move in steady steps: routing, stores, UI, tests, polish. Keep Product informed with short updates. Small wins add up. Ship the simple version first, then make it beautiful.

If any of this sounds too neat, it was not. I broke builds, swore at my terminal, and only made progress once I set a clear parity goal and stopped changing everything at once. I matched the Nuxt 2 behaviour that mattered for users and let the internals evolve slowly. Fresh start, small steps, steady momentum. You will get there.
