How we tackled our major front-end migration to Nuxt 3

At funda, migrating from Nuxt 2 to Nuxt 3 was a major project last year. Front-End Engineer Marijn Kok played a crucial role in this. Though the migration posed some challenges, 'the plan came together' in the end, making development of new features a breeze. 

Reasons for upgrading to Vue 3

Since Vue 2 was coming to its end of life at the end of the year, we had to upgrade to Vue 3 (and with that Nuxt 3) sooner rather than later. Vue 3 also offered some nice features such as Composition API, improved performance and great TypeScript support out of the box. This was a great opportunity to improve not only the speed in which we deliver features but also the developer experience.

But before we dive into the actual migration, let me first explain a bit about funda's architecture to provide you with some context.

Funda's architecture

Funda consists of several domains: there is a landing page, a search page, a listing page, and a few others. Funda’s multiple engineering teams consist of backend engineers, frontend engineers, UX, QA – the whole package. They are self-sufficient and responsible for those domains. These 'verticals’ are developed and maintained individually and aggregated at an organizational level.

See also: Game changer: the benefits of having a lighthouse architecture.

My team is responsible for two verticals, the listings page and my home. As a frontend engineer, my day-to-day job involves working on the verticals of my team, but I’m also part of the frontend cluster, a team which all frontend engineers are part of. Within the cluster we are responsible for all things that happen on a horizontal level, such as our UI library and Appshell.

Appshell

Every vertical runs a standalone Nuxt application. However, there are also parts that are shared among all verticals, such as the layout (including the header and footer) and certain services like error logging, feature toggles, analytics, etc.

This is where our Nuxt Module Appshell comes in. Nuxt Modules are a powerful tool which can modify your Nuxt application at build- and runtime, adding layouts, pages, components, plugins, and other modules.

Our Appshell module provides all the layouts, components, and services every vertical need.

Installing is as easy as defining the module in your nuxt.config.ts:

//nuxt.config.ts
export defineNuxtConfig({
  modules: ['@funda/appshell']
})

Adding that module gives you everything you need to create a new feature. Our UI library and tailwind are also included, so when you boot up your Nuxt app, this is what you are greeted with:

All that remains is to add a page in /pages and start building your feature!

The Great Move

The plan was simple:

  • Migrate our Appshell module to use Nuxt 3, Vue 3 and TypeScript.
  • Migrate our UI library and other shared components to Vue 3 and TypeScript.
  • Migrate the verticals and use the new Appshell and our UI library.

The verticals have a lot of dependencies on Appshell, so this was the first thing that needed to be done. Looking through the code and the migration guide from Nuxt, it quickly became apparent that the way we had set things up with our module was not going to be compatible with Nuxt 3.

There were a few breaking changes which had a lot of impact on how things were set up. Our module had grown a lot over its lifespan, and looking at some of the assumptions that had been made over time this was also a good moment to go over things and see if we could structure things a bit better.

As the module was pretty big and considering the many breaking changes with Nuxt 3, the most straightforward way seemed to be to start with a new repository and move things over one by one. This meant we'd be able to get something working quickly, without having to work around a lot of dependencies.

Testing

Having done a lot of TDD previously, one thing I wanted to make sure was that writing unit and component tests in our new stack would be as pleasant as writing components. Unfortunately, as soon as I started setting up the testing side of things, we hit our first roadblock.

To test a module, the workflow suggested by the Nuxt docs was to use @nuxt/test-utils, which would start a headless browser that would mount your component. You could then use playwright to test your component.

But @nuxt/test-utils was originally written for testing Nuxt applications, not Nuxt modules.

As the suggested solution was more akin to end-to-end testing – loading a headless browser for each test and just testing the output from the browser – running tests was very slow and often flaky. For a few tests this was manageable, but as our suite grew it became slower and slower. At some point the suite was taking 5 minutes – which would have been okay if we'd be running hundreds of tests, but we weren't.

It was not long before this situation became unbearable, and testing was often skipped because it was nearly impossible to write a simple test.

Unit testing

Now, this is the point where you might ask, why not just write simple unit tests? This was certainly possible for a few parts of the module, but once you got into testing runtime code like components and composables, you'd hit a roadblock. Nuxt 3 uses auto-imports and Vue 3 uses Composition API. At the time, the combination of these two things were not testable with tools like @vue/test-utils or @testing-library/vue. Let me explain.

Composition API

In Vue 2, you have Options API. We provide many plugins which components can use, and they can be used by accessing this:

<script>
	export default {
		methods: {
			isFeatureEnabled() {
				this.$featureToggles.isEnabled('my-feature')
			}
		}
	}
</script>

In your test, you can easily mock these by supplying the mocks to your render function:

render(MyComponent, {
	mocks: {
		$featureToggles: {
			isEnabled: vi.fn()
		}
	}
})

With Vue 3, we wanted to use Composition API as it has a few really nice benefits over Options API (read more on that here). The problem was that it made things harder to mock, as you didn't have access to this:

<script setup lang="ts">
	import { useFeatureToggles } from './composables'
	const { isEnabled } = useFeatureToggles()
</script>

In the test, instead of supplying mocks to your render function, you can mock the import using vi.mock:

vi.mock('useFeatureToggles', () => ({isEnabled: vi.fn()}))

render(MyComponent)

And here's where the big problem came: Nuxt uses auto-imports, ánd it was not possible to easily mock those imports.

In Nuxt, you can just write:

<script setup lang="ts">
	const { x } = useMyFeature()
</script>

without importing the composable.

What happens under the hood is this:

<script setup lang="ts">
	import { useFeatureToggles } from '#imports' // nuxt adds this line for you
	const { isEnabled } = useFeatureToggles()
</script>

Nuxt generates one huge #imports file with everything you can import.
The problem was that vitest does not know anything about Nuxt, and just running

const { isEnabled } = useFeatureToggles()

would throw up an error when you tried to import this file in your test, as it does not know where to get useMyFeature from. You'd have to mock #imports in your tests, which would create a whole new set of problems to deal with.

Getting into a hole... and then climbing out of it

So, at this point in the project, we were facing a big issue. Our code was not testable; the end-to-end solution provided by Nuxt was too slow and flaky, and unit testing was impossible because of Nuxt's auto-imports.

We spent weeks searching for a solution, trying out new things and looking for similar issues on GitHub, only to realize it was an issue we'd created a few weeks back ourselves. Our confidence in the project was at an all-time low.

Of course, we were not the only ones facing these issues, and luckily it did not take long for a new solution to appear: nuxt-vitest

nuxt-vitest was created with the aim of solving the issues we and other people were facing: making the tests run in a Nuxt environment, so auto imports are recognized, and making it easy to mock said imports.

nuxt-vitest provided a nice interface to mock auto-imported files:

import { mockNuxtImport } from 'vitest'

mockNuxtImport('useMyFeature', () => vi.fn()) // useMyFeature is now mocked!

This solved most of our issues and allowed us to unit test our components again. While this project was still in the early stages, we had something to work with! Things were looking up.

Of course, with nuxt-vitest being so new, not everything worked flawlessly. As early adopters and with our complex module we really put it to the test. Luckily, the creator of nuxt-vitest, Daniel Roe was very responsive to feedback. This is the guy who leads the Nuxt core team and works on many cool open-source projects. As we could test the package with some interesting use cases from him, we were able to help iron out some of the issues.

Today, nuxt-vitest has been merged into @nuxt/test-utils as the official tool to unit test your Nuxt app.

Help from Daniel Roe

As our Appshell module was quite complex and had a lot of features, we encountered some other issues as well: TypeScript wasn’t always working flawlessly in combination with a Nuxt module, and getting routing to work across all verticals proved to be more difficult than we originally thought, so some extra help would come in handy.

Our tech lead was still in contact with Daniel Roe and we ended up on hiring him for a few days to help us out. I recommend to definitely check out his twitch, since he often streams when he's working on Nuxt, and you might learn a thing or two watching him work!

Daniel did a review of our Appshell module, in which he made a couple of great suggestions on what we could improve. If we were stuck on an issue, we could also have a call with him to get to the root of the problem.

This was very useful, as he knows so much about Nuxt! Things we’d be stuck on for a long time, Daniel would just say: 'Oh, in Nuxt you can do this and this', and within half an hour we got something working.

Overall, having him on our side was super useful, and a great learning experience.

Before I wrap up this blog post, I want to highlight another nice feature of Nuxt 3: Layers.

Layers

Our verticals may have some shared components, such as 'listings in the neighbourhood', which we want to show on the listings page, but also on My Home.

Previously, these components were just simple Vue packages, each in a separate repository. This worked okay for us, but as these were simple Vue components without a Nuxt environment, we couldn't use any of the things Appshell would provide for us, like logging, feature flags etc.

Just like a module, layers provide components, composables and utils for your Nuxt application. Layers can extend upon other modules and layers too, so we could just install our Appshell module into our layers and have everything available that we needed such as shared composables and tailwind.

So instead of shared Vue packages we now have shared 'micro Nuxt applications' (I just made that term up – it's not a thing). This makes building shared components a little easier!

When a plan comes together

With our Appshell module working well, tests up and running and the UI library ported to Vue 3, it was now time to finally migrate our verticals. The plan was coming together!

The work that is left mostly consists of refactoring the script sections of our component to the composition API, extracting our shared Vue packages to layers and updating the tests. It's a lot of work, but with no more roadblocks in our way and all the great new features that come with Vue and Nuxt 3 it's the most fun part of the project!

Now, about a year later, most of our verticals have been migrated. One of our most important verticals, the listings page – with over 100 million page views per month – is now running on Nuxt 3 without any major issues.

The right decision

This project has been an interesting journey. Having to dig deep into Nuxt and its inner workings, our team has gained a lot of knowledge. It’s now a year after the start of the project, and all the big issues are gone. The feedback we get from our developers using Nuxt 3 is very positive.

There have been moments in the project where I wasn't sure if moving to Nuxt 3 so soon had been the right move. But every time we've seemed to hit a dead end, something has come into our path which has solved our issue – whether that was an unexpected update of an npm package, a solution in a comment somewhere deep down a GitHub thread, or Daniel helping us out.

Overall, I feel Vue 3 and its Composition API make development a breeze, and having TypeScript support is a real game changer. Now, I am now confident that we’ve made the right decision!

See also: Game changer: why we implemented an Advertising UI library (and how)

Question?
Do you have a burning question for Marijn after reading this blog? Feel free to reach out to him via email.