<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[henrychuka]]></title><description><![CDATA[henrychuka]]></description><link>https://chuka.blog</link><generator>RSS for Node</generator><lastBuildDate>Tue, 14 Apr 2026 04:29:44 GMT</lastBuildDate><atom:link href="https://chuka.blog/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Nuxt 2 to Nuxt 3 migration: the steps that worked for me]]></title><description><![CDATA[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: ...]]></description><link>https://chuka.blog/nuxt-2-nuxt-3-what-i-actually-did-and-why-it-finally-worked</link><guid isPermaLink="true">https://chuka.blog/nuxt-2-nuxt-3-what-i-actually-did-and-why-it-finally-worked</guid><category><![CDATA[Vue migration]]></category><category><![CDATA[Nuxt]]></category><category><![CDATA[nuxt3]]></category><category><![CDATA[Vue.js]]></category><category><![CDATA[Vue3]]></category><category><![CDATA[migration]]></category><category><![CDATA[vitest]]></category><category><![CDATA[vite]]></category><category><![CDATA[Pinia]]></category><category><![CDATA[Vuetify]]></category><category><![CDATA[i18n]]></category><dc:creator><![CDATA[Henry]]></dc:creator><pubDate>Mon, 29 Sep 2025 22:00:00 GMT</pubDate><content:encoded><![CDATA[<p>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 <strong>Pensionfriend</strong> (<a target="_blank" href="https://pensionfriend.de/en">pensionfriend.de</a>) from Nuxt 2/Vue 2 to Nuxt 3/Vue 3, after Vue 2 reached end of life. The goal was simple: make the platform <strong>future-proof</strong> in terms of <strong>security</strong>, <strong>performance</strong>, and <strong>modern tooling</strong>, without breaking what our users already rely on.</p>
<p>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 <a target="_blank" href="https://certificates.dev/vuejs/certificates/9cc23661-2ce2-4b0e-8f53-3c794321e019">senior Vue.js certification</a>, 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.</p>
<p>The breakthrough was abandoning a line-by-line port. I started with a clean Nuxt 3 app and migrated in slices: <strong>routing</strong>, <strong>i18n</strong>, <strong>data fetching</strong>, <strong>build</strong>, and <strong>deployment behaviour</strong>. Once the foundations were solid, everything else followed with less pain.</p>
<hr />
<h2 id="heading-what-we-optimised-for">What we optimised for</h2>
<h3 id="heading-goals">Goals</h3>
<ul>
<li><p>Keep <strong>URL and SEO behaviour</strong> stable (no surprise 404s, no broken pages)</p>
</li>
<li><p>Keep <strong>i18n routing</strong> consistent (<strong>English and German parity</strong>)</p>
</li>
<li><p>Preserve our <strong>static and prerender</strong> setup (we rely on it)</p>
</li>
<li><p>Reduce risk by moving off <strong>Vue 2 end of life</strong></p>
</li>
<li><p>Make the app easier to evolve with modern tooling (<strong>Vite</strong>, <strong>Nitro</strong>, <strong>composables</strong>, better TypeScript ergonomics)</p>
</li>
</ul>
<p>For SEO parity, we treated route parity plus meta parity as a checklist: <strong>same URLs</strong>, <strong>same canonicals</strong>, <strong>same hreflang behaviour</strong>, and the same title and description logic. If prerender failed for a route, the build should not ship.</p>
<h3 id="heading-non-goals">Non goals</h3>
<ul>
<li><p>No redesign during migration</p>
</li>
<li><p>No rewrite everything into <strong>Composition API</strong> on day one (Options API stayed where it worked)</p>
</li>
<li><p>No perfect cleanup pass up front (we kept the site buildable, then improved things as we touched them)</p>
</li>
</ul>
<hr />
<h2 id="heading-breaking-changes-you-will-hit">Breaking changes you will hit</h2>
<ul>
<li><p><code>Vite</code> and <code>Nitro</code> replace <code>Webpack</code> and the old server</p>
</li>
<li><p><code>asyncData</code> becomes <code>useAsyncData</code></p>
</li>
<li><p><code>$axios</code> moves to <code>$fetch</code></p>
</li>
<li><p>Vuex often gives way to Pinia</p>
</li>
<li><p>Sass <code>@import</code> moves to <code>@use</code> (eventually)</p>
</li>
<li><p>Mixins are better replaced with composables</p>
</li>
<li><p><code>head</code> becomes <code>useHead</code> and <code>useSeoMeta</code></p>
</li>
<li><p>Plugins change from context injection to <code>defineNuxtPlugin</code></p>
</li>
<li><p>Middleware is <strong>file-based</strong></p>
</li>
<li><p>Component libraries and SSR edges show up quickly (Vuetify, i18n, analytics scripts, and so on)</p>
</li>
</ul>
<hr />
<h2 id="heading-1-start-with-a-fresh-nuxt-3-app-but-wire-production-behaviour-early">1) Start with a fresh Nuxt 3 app, but wire production behaviour early</h2>
<p>For Pensionfriend, the non-negotiables were:</p>
<ul>
<li><p><strong>SSR enabled</strong></p>
</li>
<li><p><strong>Static output</strong> (<code>nitro.preset = "static"</code>)</p>
</li>
<li><p><strong>Prerendering</strong> and <strong>crawling links</strong></p>
</li>
<li><p>Prerendering a known list of <strong>dynamic routes</strong>, including locale variants</p>
</li>
<li><p><strong>Failing fast</strong> when prerender breaks (broken static pages are silent disasters)</p>
</li>
</ul>
<p>Here is the shape of the Nuxt 3 config that mattered most. I am keeping the routes generic here. Swap them for your own.</p>
<pre><code class="lang-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/&lt;section&gt;" },
  { route: "/de/&lt;section&gt;" },
];

const PRERENDER_ROUTES = urlMaps.map((u) =&gt; 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) =&gt; {
        if (route.error) {
          console.error(`Prerender failed for ${route.route}`, route.error);
          process.exit(1);
        }
      },
      "prerender:done": ({ failedRoutes }) =&gt; {
        if (failedRoutes.length &gt; 0) {
          console.error("Failed routes:", failedRoutes);
          process.exit(1);
        }
      },
    },
  },

  i18n: {
    locales: [{ code: "en" }, { code: "de" }],
    defaultLocale: "en",
    strategy: "prefix",
    customRoutes: "config",
    pages: routeDeclarations(),
  },
});
</code></pre>
<p>We used <code>crawlLinks: true</code>, 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.</p>
<p>Folder shape I stuck with:</p>
<pre><code class="lang-plaintext">src/
  assets/
  components/
  composables/
  layouts/
  middleware/
  pages/
  plugins/
  server/api/   # optional, for server only secrets
  sdk/graphql
  stores/
  app.vue
  nuxt.config.ts
</code></pre>
<hr />
<h2 id="heading-2-pages-first-reproduce-your-data-fetch-and-routes">2) Pages first: reproduce your data fetch and routes</h2>
<p>I still recommend <strong>pages first</strong>, but on Pensionfriend, it came with two constraints:</p>
<ul>
<li><p>We needed <strong>prerender parity</strong>, including locale routes</p>
</li>
<li><p>We needed <strong>URL stability</strong> because we use <strong>custom i18n routes</strong></p>
</li>
</ul>
<h3 id="heading-replace-asyncdata-with-useasyncdata">Replace <code>asyncData</code> with <code>useAsyncData</code></h3>
<pre><code class="lang-plaintext">&lt;!-- pages/blog/[slug].vue --&gt;
&lt;script setup lang="ts"&gt;
const route = useRoute()
const slug = route.params.slug as string

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

useSeoMeta({
  title: post.value?.title ?? 'Blog',
  description: post.value?.excerpt ?? ''
})
&lt;/script&gt;

&lt;template&gt;
  &lt;main class="container"&gt;
    &lt;h1 v-if="post"&gt;{{ post.title }}&lt;/h1&gt;
    &lt;p v-else-if="pending"&gt;Loading…&lt;/p&gt;
    &lt;p v-else-if="error"&gt;Could not load this article.&lt;/p&gt;
    &lt;article v-else v-html="post.body" /&gt;
  &lt;/main&gt;
&lt;/template&gt;
</code></pre>
<p>If you need API keys on the server, use a Nitro server route and keep secrets out of the browser.</p>
<hr />
<h2 id="heading-prerender-dynamic-routes-the-way-we-ended-up-doing-it">Prerender dynamic routes: the way we ended up doing it</h2>
<p>In Nuxt 2, we had <code>generate.routes</code>. In Nuxt 3, we ended up with a routes folder pattern:</p>
<ul>
<li><p><code>dynamic-routes.ts</code> aggregates multiple generators</p>
</li>
<li><p>smaller scripts like <code>generate-appointment-routes.ts</code>, <code>generate-blog-articles-routes.ts</code>, and so on</p>
</li>
<li><p>each generator returns <code>{ route, payload? }</code> so the route list is reusable and easy to review</p>
</li>
</ul>
<p>Example (simplified from our appointment generator):</p>
<pre><code class="lang-plaintext">// routes/route-scripts/generate-appointment-routes.ts
export type UrlMap = { route: string; payload?: Record&lt;string, any&gt; };

export async function generateAppointmentRoutes(): Promise&lt;UrlMap[]&gt; {
  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) =&gt; {
    langs.forEach(({ code, section, confirm, confirmation }) =&gt; {
      const base = `/${code}/${section}/${type}`;
      urlMap.push({ route: base });
      urlMap.push({ route: `${base}/${confirm}` });
      urlMap.push({ route: `${base}/${confirmation}` });
    });
  });

  return urlMap;
}
</code></pre>
<p>Then <code>routes/dynamic-routes.ts</code> collects them:</p>
<pre><code class="lang-plaintext">// routes/dynamic-routes.ts
import { generateAppointmentRoutes } from "./route-scripts/generate-appointment-routes";

export default async function dynamicRoutes() {
  const appointment = await generateAppointmentRoutes();
  return [...appointment];
}
</code></pre>
<p>It is not fancy, but it made routes predictable and reviewable, and it made prerender failures easier to debug.</p>
<hr />
<h2 id="heading-3-layouts-components-and-moving-away-from-mixins">3) Layouts, components, and moving away from mixins</h2>
<h3 id="heading-components-keep-options-api-convert-gradually">Components: keep Options API, convert gradually</h3>
<p>Vue 3 still supports <strong>Options API</strong>, so I did not convert everything. I converted what I touched.</p>
<h3 id="heading-mixins-to-composables-as-you-touch-them">Mixins to composables, as you touch them</h3>
<p>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.</p>
<pre><code class="lang-plaintext">// composables/appointment/useBookedAppointment.ts
export function useBookedAppointment() {
  const booked = ref(false)
  async function book() {
    booked.value = true
  }
  return { booked, book }
}
</code></pre>
<hr />
<h2 id="heading-4-i18n-on-nuxt-3-custom-routes">4) i18n on Nuxt 3: custom routes</h2>
<p>For Pensionfriend, we did not just translate strings. We translated <strong>routes</strong>.</p>
<p>Instead of relying purely on file-based routing, we used <code>customRoutes: "config"</code> and explicitly mapped route names to locale paths:</p>
<pre><code class="lang-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;
}
</code></pre>
<p>That was one of the biggest parity wins. The URL strategy was not just a preference; it was part of the product.</p>
<p>One gotcha that still applies: do not register multiple i18n plugins, otherwise you will see:<br /><strong>Cannot redefine property: $switchLocalePath</strong></p>
<hr />
<h2 id="heading-5-store-vuex-to-pinia-or-stay-on-vuex-4-temporarily">5) Store: Vuex to Pinia, or stay on Vuex 4 temporarily</h2>
<p>We kept Vuex alive while we migrated, and moved new state into composables or Pinia patterns as we touched features.</p>
<p>The rule that kept things simple:</p>
<ul>
<li><p><strong>New work</strong> goes to composables or Pinia (if necessary)</p>
</li>
<li><p><strong>Legacy store logic</strong> can stay Vuex until you have time</p>
</li>
</ul>
<hr />
<h2 id="heading-6-ui-vuetify-2-to-vuetify-3-and-removing-a-component-submodule">6) UI: Vuetify 2 to Vuetify 3, and removing a component submodule</h2>
<p>Vuetify 2 to 3 is already a migration on its own. On top of that, we had another blocker.</p>
<h3 id="heading-the-shared-component-library-submodule-problem">The shared component library submodule problem</h3>
<p>In Nuxt 2, we used a shared <strong>component library</strong> as a submodule. Over time, it became hard to maintain. When we started the Nuxt 3 migration, we realised:</p>
<ul>
<li><p><strong>Keeping the submodule meant upgrading it to Vue 3</strong></p>
</li>
<li><p><strong>Other systems still depended on it in Vue 2</strong></p>
</li>
<li><p><strong>A proper upgrade risked breaking those other systems</strong></p>
</li>
</ul>
<p>So we made the pragmatic call:</p>
<ul>
<li><p><strong>removed the submodule</strong></p>
</li>
<li><p><strong>ported only the components and dependencies we needed into the Nuxt 3 project</strong></p>
</li>
<li><p><strong>migrated incrementally as we converted screens</strong></p>
</li>
</ul>
<p>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.</p>
<hr />
<h2 id="heading-7-vite-specifics">7) Vite specifics</h2>
<p>A couple of patterns that helped:</p>
<ul>
<li><p>be explicit about SSR troublemakers (for example <code>ssr.noExternal</code> and sometimes <code>optimizeDeps.include</code>)</p>
</li>
<li><p>Keep aliases tidy, especially in legacy setups</p>
</li>
<li><p>If you disable prefetch or preload in the build manifest, leave a short comment explaining why</p>
</li>
</ul>
<hr />
<h2 id="heading-8-prerender-and-seo-parity-the-part-that-kept-us-safe">8) Prerender and SEO parity, the part that kept us safe</h2>
<p>Because we were static and prerender heavy, we treated prerender as a first-class test.</p>
<p>What helped most:</p>
<ul>
<li><p><code>crawlLinks: true</code> for coverage</p>
</li>
<li><p><code>ignore</code> for known routes that should never be prerendered</p>
</li>
<li><p>failing fast on broken prerender routes using Nitro hooks</p>
</li>
</ul>
<p>If you are migrating an SEO sensitive site, this is the difference between "works locally" and "safe to ship".</p>
<hr />
<h2 id="heading-9-sass-gotchas-import-to-use-and-the-bridge-period">9) Sass gotchas: <code>@import</code> to <code>@use</code>, and the bridge period</h2>
<p>Yes, <code>@use</code> is the direction. In a large legacy codebase, I kept <code>@import</code> for a while, so styling refactors would not block shipping, then converted gradually.</p>
<p>Example:</p>
<pre><code class="lang-plaintext">// options.vite.ts
css: {
  preprocessorOptions: {
    scss: {
      additionalData: `@import "~/assets/styles/variables";`,
      silenceDeprecations: ["import", "global-builtin", "legacy-js-api"],
      quietDeps: true,
    },
  },
},
</code></pre>
<hr />
<h2 id="heading-10-testing-jest-to-vitest-while-adding-tests-during-the-migration">10) Testing: Jest to Vitest, while adding tests during the migration</h2>
<p>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.</p>
<pre><code class="lang-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,
  },
})
</code></pre>
<pre><code class="lang-plaintext">// test/setup.ts
import { expect } from 'vitest'
import matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
</code></pre>
<hr />
<h2 id="heading-11-a-realistic-low-stress-migration-plan">11) A realistic, low-stress migration plan</h2>
<p>This is the part most posts skip:</p>
<ul>
<li><p>Nuxt 2 stayed in production while Nuxt 3 matured</p>
</li>
<li><p>We built Nuxt 3 in parallel and ran QA properly</p>
</li>
<li><p>Once Nuxt 3 was ready, we switched the deployment target and cut over</p>
</li>
</ul>
<p>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.</p>
<hr />
<h2 id="heading-common-gotchas-i-hit-and-fixes">Common gotchas I hit (and fixes)</h2>
<ul>
<li><p><strong>Cannot redefine property: $switchLocalePath</strong>: multiple i18n plugins registered</p>
</li>
<li><p>404s after static build: missing routes in the prerender list, especially locale variants</p>
</li>
<li><p>Prerender flakiness: reduce concurrency, add ignore list, fail fast on route errors</p>
</li>
<li><p>Vuetify styles missing: <code>build.transpile</code> plus <code>ssr.noExternal</code> plus include <code>vuetify/styles</code></p>
</li>
<li><p>Shared component library becomes a blocker: decide early, dual build, migrate the library, or vendor it</p>
</li>
<li><p>SCSS migration becomes a time sink: keep <code>@import</code> temporarily, convert to <code>@use</code> gradually</p>
</li>
</ul>
<hr />
<h2 id="heading-what-improved-after-the-migration">What improved after the migration</h2>
<ul>
<li><p>A <strong>future-proof</strong> foundation (Vue 3 plus Nuxt 3 ecosystem) with less legacy drag</p>
</li>
<li><p>Cleaner separation of server-only concerns via Nitro server routes</p>
</li>
<li><p>Faster iteration loop with Vite, especially TypeScript and styles</p>
</li>
<li><p>More confidence in shipping changes because prerender and tests acted like guardrails</p>
</li>
</ul>
<hr />
<h2 id="heading-final-thought">Final thought</h2>
<p>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.</p>
<p>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.</p>
]]></content:encoded></item></channel></rss>