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

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 asyncData
→ useAsyncData
<!-- 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).
Option A — Pinia (recommended)
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:
Vuex | Pinia |
state: () => ({}) | state: () => ({}) |
getters | getters (or computed in components) |
mutations + actions | actions (mutations not needed) |
mapState/mapActions | useStore() then plain access |
modules | multiple 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 likevariant="outlined"
anddensity="compact"
.Colours:
color="primary"
remains, but theming is configured viacreateVuetify({ theme })
.Components: some APIs changed (e.g.
v-btn
’stext
→variant="text"
,v-text-field
’soutlined
→variant="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 2 | Vuetify 3 |
<v-btn text> | <v-btn variant="text"> |
<v-btn outlined> | <v-btn variant="outlined"> |
<v-text-field outlined> | <v-text-field variant="outlined"> |
dense | density="compact" |
v-icon (font) | still works; install @mdi/font |
this.$vuetify.theme | createVuetify({ theme }) |
7) Vite specifics (what replaces Webpack-isms)
Aliases →
vite.resolve.alias
(shown above).Global SCSS vars →
vite.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-codedimport.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 referencenamespace.$var
oras *
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:
Jest | Vitest |
jest.fn() | vi.fn() |
jest.mock() | vi.mock() |
expect(...).toHaveBeenCalled() | same |
jest.useFakeTimers() | vi.useFakeTimers() |
11) A realistic, low-stress migration plan
Fresh Nuxt 3 app + config above. Boot the home page.
Add i18n with the same URL strategy. Verify
/
and/de
.Port one page pattern (blog post):
useAsyncData
+ SEO.Add Nitro prerender with a short manual slugs list; build once.
Choose Pinia or temporarily wire Vuex 4; port one store.
Wire Vuetify 3 (if you used it) and convert one screen: focus on variants/density props.
Swap Jest → Vitest for new tests; leave old tests as you go.
Migrate Sass to
@use
/@forward
; addadditionalData
for globals.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'
tocss
andbuild.transpile: ['vuetify']
; setssr.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. 🚀
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.