January 2nd, 2026

{ Engineering }
How We Migrated Our Largest App from Nuxt 2 to Nuxt 3

Festus
When Vue 3 was announced, it changed the way we thought about building Vue applications.
The introduction of the Composition API promised clearer logic reuse, better performance, stronger TypeScript support, and codebases that would scale more gracefully over time. It also tackled long-standing pain points of the Options API, especially around mixins, limited type safety, and growing complexity in large applications.
For us, migrating was an obvious decision. The benefits were clear, and Nuxt 3 offered a more modern and maintainable foundation for the future.
The real challenge was not why we should migrate, but how we could realistically pull it off.
We initially attempted an automatic migration following the official documentation. That approach quickly fell apart. The application was massive, with over 296,554 lines of code across all tracked files. We ran into library incompatibilities, Node version conflicts, and Vue 2 only dependencies. It became clear that a straight upgrade path would not work.
So we made a bold call: a complete rewrite.
At the time, we were a team of three engineers and an intern. Pausing product development for months just to migrate an app was not an option. Looking back now, it is still surprising that we managed to ship this successfully.
Yet here we are.
Let’s dive into how we did it.
1. Setting Up a New Nuxt 3 Project

We went deep into the Nuxt 3 documentation to truly understand how things worked under the hood. Once we had a good grasp of the framework, we set up a fresh Nuxt 3 project from scratch.
This included:
- Project structure and folder conventions
- TypeScript configuration
- Prettier and ESLint setup
- Installing required libraries and packages
This phase quickly revealed several challenges:
- Some libraries were not compatible with newer Node versions
- Some packages required Vue 3, specific or Nuxt 3, specific versions
- Vue’s move from Webpack to Vite introduced breaking changes
We documented every incompatible dependency. Some needed alternatives, some required upgrades, and others had to be rewritten entirely. A few of those rewrites eventually became open source.
2. Identifying Dependencies
We started thinking about the app like a LEGO house. Everything is connected, but some pieces are foundational.
We categorised dependencies into global and local:
Global Dependencies
- Global styles
- Modules
- Plugins
- Filters
- Global mixins
- Services
- Static assets
- Layouts
- Middleware
- Store
Local Dependencies
- Components
- Pages
- Local mixins
This categorisation helped us understand what could be migrated independently and what would have cascading effects.
3. Prioritising Dependencies
Within the global dependencies, we ranked items based on the number of other parts of the app that depended on them.
The idea was simple: start with the least coupled pieces and work our way up.
For example:
- Static assets, middleware, and styles had fewer dependents
- Layouts depended on those
- Stores and global mixins sat at the top with the most dependents
This allowed parts of the team to work in isolation while minimising blockers and conflicts.
4. Incorporating New Paradigms
Nuxt 3 introduced several major changes that required deliberate architectural decisions:
- Composables replaced mixins
- Filters were removed, replaced by utility functions
- TypeScript became a first-class citizen
- $fetch was introduced for async HTTP requests
These weren’t just syntax changes; they required rethinking how we structured shared logic, data fetching, and state management.


5. Implementation in Practice
Reading this so far, it might sound like everything was carefully planned and executed step by step. In reality, that was far from the truth.
While setting up the project and identifying dependencies went relatively smoothly, the real work quickly exposed gaps in our assumptions. Some patterns we relied on in Nuxt 2 simply did not translate to Nuxt 3. Other approaches worked in theory but broke down in practice, forcing us to rethink and adapt.
To stay sane, we introduced a few guardrails:
- A checklist of common pitfalls and traps to avoid
- A success checklist to validate migrated features
- Type generation for plugins to ensure proper TypeScript integration
Work was split across engineers based on folders, domains, and functionality. This only worked because communication was constant and intentional. Decisions were shared quickly, blockers were raised early, and alignment happened almost in real time to avoid duplicated effort.
6. Testing
Testing was the most critical part of the migration.
Our existing automated test suite wasn’t reliable due to the scale of change. We leaned heavily on manual and exploratory testing.
We tested on multiple levels:
- Every migrated component, page, or feature was reviewed and tested by multiple engineers
- Core user flows were documented and tested rigorously
- Engineers outside the team were brought in to test and provide fresh feedback
Their feedback was invaluable and helped uncover edge cases we would have otherwise missed.
7. Catch-Up Mode
This was one of the hardest parts.
Our original timeline, with all hands on deck and no new feature development, was three months. That wasn’t realistic.
Product development couldn’t stop.
So while migrating the app, we were also actively developing new features in the Nuxt 2 codebase. Every new feature had to be:
- Built in the old app
- Reimplemented in the new app
It was exhausting and inefficient, but unavoidable. Eventually, we caught up, stabilised the codebase, and launched the first production version of the Nuxt 3 app.
8. Post Mortem
After launch, we finally had room to breathe.
This phase was about paying down the technical debt we knowingly accumulated during the migration. We focused on removing redundant packages, improving type safety, auditing performance, and refactoring areas that were rushed under pressure.
The work did not stop there. Migration is not a finish line. It is the start of a new baseline that still requires continuous improvement.
Lessons Learned and What We Would Do Differently
Looking back, there are a few key lessons that stand out.
1. A rewrite is sometimes the right answer
We spent time trying to force an automated migration before accepting reality. For large, long-lived applications, a clean rewrite can be more predictable and less risky than a fragile hybrid state.
2. Migration while shipping is extremely expensive
Duplicating work across two codebases was exhausting. If we were to do this again, we would look harder at feature freezes or scoped release windows to reduce duplication.
3. Manual testing matters more than you think
Automated tests struggled to keep up with the scale of change. Having multiple engineers manually test real user flows caught issues that no test suite would have caught.
Final Thoughts
Migrating our largest app from Nuxt 2 to Nuxt 3 was one of the most challenging projects we’ve taken on. It tested our technical skills, communication, and resilience as a team.
But it was worth it.
The result is a faster, more maintainable, future-proof application, and a team that now deeply understands both the cost and value of large‑scale technical migrations.
Next article


Christiana Uzonwanne;
{ Designer } Cowrywise