Nuxt 2 → Nuxt 3: what I actually did (and why it finally worked)

HenryHenry
11 min read

I’ve now migrated two Nuxt 2 projects to Nuxt 3. It wasn’t easy at first, but it got smoother once I stopped “porting line-by-line” and instead created a fresh Nuxt 3 app and moved things over gradually, starting with the base architecture (routing, i18n, data-fetching, build). This post is my practical, human-readable checklist — with code — covering:

  • Base Nuxt 3 setup (Vite, Nitro, runtime config)

  • Pages, layouts, components (Options API → <script setup> when you’re ready)

  • Vuex → Pinia (or Vuex 4 if you must)

  • Vuetify 2 → Vuetify 3 breaking changes

  • @nuxtjs/i18n on Nuxt 3 (strategy, helpers, common traps)

  • Dependencies that won’t follow you from Nuxt 2

  • Jest → Vitest test migration (with Vue Test Utils)

  • A small plan to ship an MVP migration without going mad


1) Start with a fresh Nuxt 3 app

Scaffold a clean project and wire the essentials before you touch features.

npx nuxi init web
cd web
pnpm i # or npm/yarn

nuxt.config.ts (sane defaults you can paste):

// nuxt.config.ts
export default defineNuxtConfig({
  // Keep code under /web (optional)
  srcDir: 'web/',

  typescript: { strict: true },

  // Modules you probably need early
  modules: [
    '@nuxtjs/i18n',     // i18n v8 for Nuxt 3
    '@pinia/nuxt'       // if you plan to use Pinia
  ],

  css: ['~/assets/main.css'],

  i18n: {
    locales: [
      { code: 'en', iso: 'en-GB', file: 'en.json', name: 'English' },
      { code: 'de', iso: 'de-DE', file: 'de.json', name: 'Deutsch' }
    ],
    defaultLocale: 'en',
    strategy: 'prefix_except_default',
    lazy: true,
    langDir: 'locales',
    detectBrowserLanguage: false
  },

  nitro: {
    prerender: {
      routes: ['/', '/blog'] // extend with dynamic slugs later
    }
  },

  runtimeConfig: {
    // server only
    API_SECRET: process.env.API_SECRET || '',
    // exposed to client
    public: {
      API_BASE: process.env.API_BASE || '',
      APP_ENV: process.env.APP_ENV || 'local'
    }
  },

  vite: {
    // Handy for SCSS variables, aliases, etc.
    css: {
      preprocessorOptions: {
        scss: {
          additionalData: `@use "~/assets/variables.scss" as *;`
        }
      }
    },
    resolve: {
      alias: {
        '@api': '/web/lib/api'
      }
    }
  }
})

Folder shape I stuck with:

web/
  assets/
  components/
  composables/
  layouts/
  sdk/graphql
  middleware/
  pages/
  plugins/
  server/api/         # optional, for hiding secrets
  app.vue
  nuxt.config.ts

2) Pages first: reproduce your data-fetch & routes

Replace asyncDatauseAsyncData

<!-- web/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(`${useRuntimeConfig().public.API_BASE}/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">Couldn’t load this article.</p>
    <article v-else v-html="post.body" />
  </main>
</template>

If you need API keys on the server, add a tiny endpoint:

// web/server/api/blog/[slug].get.ts
export default defineEventHandler(async (event) => {
  const { slug } = getRouterParams(event)
  const cfg = useRuntimeConfig()
  return $fetch(`${cfg.public.API_BASE}/blog/${slug}`, {
    headers: { 'x-api-secret': cfg.API_SECRET }
  })
})

…and call it from the page with $fetch('/api/blog/my-slug').

Prerender dynamic routes (Nitro)

In Nuxt 2 you probably used generate.routes. In Nuxt 3, create a build step that writes a file with slugs.

// scripts/build-routes.ts
import { writeFileSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'

async function main() {
  // TODO: fetch real slugs from your CMS/API
  const slugs = ['state-pension-age-explained', 'why-pensions-matter']
  const routes = ['/', '/blog', ...slugs.map(s => `/blog/${s}`)]
  mkdirSync(resolve('web/.generated'), { recursive: true })
  writeFileSync(resolve('web/.generated/routes.json'), JSON.stringify(routes, null, 2))
}
main().catch(e => { console.error(e); process.exit(1) })
// nuxt.config.ts
// @ts-expect-error file generated at build time
import routes from './.generated/routes.json'

export default defineNuxtConfig({
  nitro: { prerender: { routes } }
})
Build sequence:node scripts/build-routes.ts
npx nuxi generate

3) Layouts, components, and moving away from mixins

Layouts: same concept, new API

<!-- web/layouts/default.vue -->
<script setup>
useHead({ titleTemplate: t => (t ? `${t} · MySite` : 'MySite') })
</script>

<template>
  <div>
    <Header />
    <main><slot /></main>
    <Footer />
  </div>
</template>

Per-page layout:

<script setup lang="ts">
definePageMeta({ layout: 'default' })
</script>

Components: keep Options API, convert gradually

Vue 3 still runs Options API. You can convert to <script setup> later:

<!-- Options API still works -->
<script>
export default {
  props: { open: Boolean },
  mounted() { /* … */ },
  methods: { close() { this.$emit('close') } }
}
</script>

…and the equivalent when you’re ready:

<!-- <script setup> version -->
<script setup lang="ts">
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ (e:'close'): void }>()
function close() { emit('close') }
onMounted(() => { /* … */ })
</script>

Mixins → Composables (do this as you touch them)

// web/composables/usePagination.ts
export function usePagination(init = { page: 1, pageSize: 10 }) {
  const page = ref(init.page)
  const pageSize = ref(init.pageSize)
  const offset = computed(() => (page.value - 1) * pageSize.value)
  function next(){ page.value++ }
  function prev(){ page.value = Math.max(1, page.value - 1) }
  return { page, pageSize, offset, next, prev }
}

Use it:

<script setup lang="ts">
const { page, next, prev } = usePagination()
</script>

4) i18n on Nuxt 3 (@nuxtjs/i18n v8)

  • Keep your URL strategy (prefix_except_default) for parity.

  • Prefer composables (useI18n, useSwitchLocalePath) to global injections.

  • Common trap: don’t register multiple i18n plugins; it can cause “Cannot redefine property: $switchLocalePath”.

Minimal config shown earlier. Tiny “compat” helper if you used $t everywhere:

// web/plugins/i18n-compat.client.ts
export default defineNuxtPlugin(() => {
  const { t } = useI18n()
  return { provide: { t } } // usage: const { $t } = useNuxtApp()
})

5) Store: Vuex → Pinia (or stay on Vuex 4)

Short answer: new work goes Pinia; if you need a drop-in, Vuex 4 runs on Vue 3 but you’ll wire it yourself (Nuxt 3 doesn’t ship Vuex).

Install via module (already in modules):

// nuxt.config.ts
modules: ['@pinia/nuxt']

Create a store:

// web/stores/user.ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
  state: () => ({ user: null as null | { id: string; name: string } }),
  getters: { isLoggedIn: (s) => !!s.user },
  actions: {
    login(u: { id: string; name: string }) { this.user = u },
    logout() { this.user = null }
  }
})

Use it:

<script setup lang="ts">
const user = useUserStore()
user.login({ id: '1', name: 'Sara' })
</script>

Mapping from Vuex:

VuexPinia
state: () => ({})state: () => ({})
gettersgetters (or computed in components)
mutations + actionsactions (mutations not needed)
mapState/mapActionsuseStore() then plain access
modulesmultiple defineStore() files

SSR note: Pinia hydrates automatically with the module.

Option B — Vuex 4 (if you cannot switch yet)

Install and create the store, then expose it via a plugin:

pnpm add vuex@^4
// web/store/index.ts
import { createStore } from 'vuex'
export const store = createStore({
  state: () => ({ count: 0 }),
  mutations: {
    inc(state) { state.count++ }
  }
})
// web/plugins/vuex.client.ts
import { store } from '~/store'
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(store)
})

Usage remains this.$store in Options components or useStore() in setup. This is fine as a temporary bridge, but Pinia is the path forward.


6) Vuetify 2 → Vuetify 3 (what bit me)

Highlights:

  • Variants & density: outlined, filled, dense moved to props like variant="outlined" and density="compact".

  • Colours: color="primary" remains, but theming is configured via createVuetify({ theme }).

  • Components: some APIs changed (e.g. v-btn’s textvariant="text", v-text-field’s outlinedvariant="outlined").

  • Grid: still v-row / v-col but breakpoint props may differ.

  • Forms: still rules, but some validation timing changed with Vue 3 reactivity.

Basic setup with Nuxt 3 + Vite:

pnpm add vuetify @mdi/font
pnpm add -D vite-plugin-vuetify
// nuxt.config.ts
import vuetify from 'vite-plugin-vuetify'
export default defineNuxtConfig({
  build: { transpile: ['vuetify'] },
  vite: {
    ssr: { noExternal: ['vuetify'] },
    plugins: [vuetify({ autoImport: true })]
  },
  css: ['vuetify/styles', '@mdi/font/css/materialdesignicons.min.css']
})
// web/plugins/vuetify.ts
import { createVuetify } from 'vuetify'
export default defineNuxtPlugin((nuxtApp) => {
  const vuetify = createVuetify({
    theme: {
      defaultTheme: 'light',
      themes: {
        light: { colors: { primary: '#1976D2' } }
      }
    }
  })
  nuxtApp.vueApp.use(vuetify)
})

Before → After quick map

Vuetify 2Vuetify 3
<v-btn text><v-btn variant="text">
<v-btn outlined><v-btn variant="outlined">
<v-text-field outlined><v-text-field variant="outlined">
densedensity="compact"
v-icon (font)still works; install @mdi/font
this.$vuetify.themecreateVuetify({ theme })

7) Vite specifics (what replaces Webpack-isms)

  • Aliasesvite.resolve.alias (shown above).

  • Global SCSS varsvite.css.preprocessorOptions.scss.additionalData.

  • Dynamic imports still work; ESM-only libs are happier.

  • Optimise deps (problem libs):

// nuxt.config.ts
vite: {
  optimizeDeps: { include: ['dayjs', 'some-esm-only-lib'] },
  ssr: { noExternal: ['vuetify'] }
}
  • Env: prefer runtimeConfig over hard-coded import.meta.env for SSR-safe values.

8) Dependencies you may leave behind (or swap)

  • @nuxtjs/axios → use $fetch or your own API wrapper.

  • nuxt/auth (classic) → use a first-party OIDC/OAuth client or Auth.js; or roll a server route.

  • nuxt-lazy-hydrate<ClientOnly> + IntersectionObserver.

  • @nuxt/content v1 → migrate to @nuxt/content v2.

  • Old CommonJS-only UI libs → prefer ESM builds (some need ssr.noExternal).

9) Sass gotchas: @import@use (dart-sass + Vite)

Nuxt 3 uses Vite + dart-sass. The old @import still works but is deprecated; switch to @use/@forward for cleaner, faster builds.

A. Global variables/mixins (inject everywhere)

Create web/assets/variables.scss:

// variables.scss
$primary: #1976d2;
$radius: 10px;

@mixin card {
  border-radius: $radius;
  box-shadow: 0 8px 20px rgba(0,0,0,0.06);
}

Tell Vite to inject it (already in the config above):

vite: {
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "~/assets/variables.scss" as *;`
      }
    }
  }
}

Now any .scss can use $primary and @include card; without importing.

B. Local @use inside components

/* AnyComponent.vue <style lang="scss"> */
@use "~/assets/variables.scss" as *;

.button {
  background: $primary;
  @include card;
}

Note: with Vite you don’t need the old webpack ~ prefix; use the Nuxt alias ~ or @/ paths as shown.

C. Converting @import to @use

/* before */
@import "@/assets/variables";

/* after (namespaced) */
@use "~/assets/variables.scss" as vars;
.my-card { border-radius: vars.$radius; }

/* after (globally, if you really want) */
@use "~/assets/variables.scss" as *;

D. Common Sass pitfalls I hit

  • Make sure you actually have the sass package installed (dart-sass). node-sass is deprecated.

  • Use .scss extension if you write curly-brace syntax; .sass is the indented variant.

  • @use is namespaced by default. Either reference namespace.$var or as * if you accept global names.

  • @use runs each file once. If you need to re-export a set of tokens, create an index file that uses @forward:

// assets/design-tokens.scss
@forward "./variables.scss";
@forward "./colors.scss";

When migrating Vuetify themes, do not rely on editing global CSS variables from Vuetify 2; use createVuetify({ theme }).

10) Testing: Jest → Vitest (fast wins)

Install:

pnpm add -D vitest @vitest/ui @vue/test-utils@next jsdom

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 (Jest-style globals you miss):

import { expect } from 'vitest'
import matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)

Simple component test:

// components/Hello.spec.ts
import { mount } from '@vue/test-utils'
import Hello from './Hello.vue'

describe('Hello', () => {
  it('renders name', () => {
    const w = mount(Hello, { props: { name: 'Sara' } })
    expect(w.text()).toContain('Sara')
  })
})

Run:

pnpm vitest
# or UI:
pnpm vitest --ui

Mapping from Jest:

JestVitest
jest.fn()vi.fn()
jest.mock()vi.mock()
expect(...).toHaveBeenCalled()same
jest.useFakeTimers()vi.useFakeTimers()

11) A realistic, low-stress migration plan

  1. Fresh Nuxt 3 app + config above. Boot the home page.

  2. Add i18n with the same URL strategy. Verify / and /de.

  3. Port one page pattern (blog post): useAsyncData + SEO.

  4. Add Nitro prerender with a short manual slugs list; build once.

  5. Choose Pinia or temporarily wire Vuex 4; port one store.

  6. Wire Vuetify 3 (if you used it) and convert one screen: focus on variants/density props.

  7. Swap Jest → Vitest for new tests; leave old tests as you go.

  8. Migrate Sass to @use/@forward; add additionalData for globals.

  9. Move the rest in small batches, keeping the site buildable every day.


Common gotchas I hit (and fixes)

  • “Cannot redefine property: $switchLocalePath” → you registered multiple i18n plugins. Keep only @nuxtjs/i18n and remove custom injections.

  • 404s on dynamic pages after static build → you didn’t add slugs to nitro.prerender.routes. Start with a manual list, automate later.

  • SCSS variables not found → add vite.css.preprocessorOptions.scss.additionalData and migrate @import@use.

  • Vuetify styles missing → add 'vuetify/styles' to css and build.transpile: ['vuetify']; set ssr.noExternal for Vuetify.

  • Old mixins everywhere → don’t refactor them all at once. Create composables as you modify the feature.

  • Webpack-style ~ imports → not needed in Vite; use ~/ or @/ aliases.


Final thought (read this when you get stuck)

You don’t need to “win the migration” in one week or one month to be honest. Stand up a clean Nuxt 3 skeleton, get one dynamic page loading, and lock your URL/i18n shape. From there it’s just steady steps: stores → UI → tests → polish. Migrate Sass to @use, switch one store to Pinia, nudge one Vuetify screen to v3, and keep shipping.

Small wins compound. By the time your second page renders and your first Vitest suite passes, you’ll feel the momentum. You’ve got this, ship the boring version first, then make it beautiful. 🚀

0
Subscribe to my newsletter

Read articles from Henry directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Henry
Henry

Senior software engineer with seven years of experience working in distributed, cross-cultural, and fast-paced teams. Expert in TypeScript, JavaScript, React, Vue, Ruby and modern architectures. I have collaborated with high-performing teams to deliver quality, reliable, and maintainable software for clients and end-users. My professional journey encompasses working with both global companies and startups. I have contributed to SaaS, B2B, and B2C products across various industries, consistently driving innovation and excellence in software development.