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) 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, 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
ViteandNitroreplaceWebpackand the old serverasyncDatabecomesuseAsyncData$axiosmoves to$fetchVuex often gives way to Pinia
Sass
@importmoves to@use(eventually)Mixins are better replaced with composables
headbecomesuseHeadanduseSeoMetaPlugins change from context injection to
defineNuxtPluginMiddleware 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.
// 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:
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
<!-- 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.tsaggregates multiple generatorssmaller scripts like
generate-appointment-routes.ts,generate-blog-articles-routes.ts, and so oneach generator returns
{ route, payload? }so the route list is reusable and easy to review
Example (simplified from our appointment generator):
// 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:
// 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.
// 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:
// 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.noExternaland sometimesoptimizeDeps.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: truefor coverageignorefor known routes that should never be prerenderedfailing 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:
// 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.
// 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,
},
})
// 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.transpileplusssr.noExternalplus includevuetify/stylesShared component library becomes a blocker: decide early, dual build, migrate the library, or vendor it
SCSS migration becomes a time sink: keep
@importtemporarily, convert to@usegradually
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.