Skip to content

Acelab · Material Hub

Upgrading a large Vue application, then paying down what came across

The main application crossed from Nuxt 2 to Nuxt 3 and Vue 3 while the product kept shipping. I worked on the upgrade, then typed the app’s API client, swapped several mixins for composables and moved some state from Vuex to Pinia, with unit tests.

Problem
A large production web app had to cross a breaking framework upgrade while the product kept shipping, then pay down the old patterns it carried across.
My part
I was a major contributor to the Nuxt 3 upgrade, which a teammate led, and to the cleanup that followed; my upgrade areas were projects, account, schedules, comments and notifications.
Result
The product kept shipping through the move to Nuxt 3 and Vue 3, merged in November 2025; my April–May 2026 cleanup typed the API client and replaced several mixins with typed composables, with unit tests.

The problem

The main web app behind Acelab’s Material Hub (opens in a new tab), a materials platform for architects, owners and manufacturers, is a Nuxt application of more than a thousand Vue files. In 2025 it had to move from Nuxt 2 and Vue 2 to Nuxt 3 and Vue 3 while the product kept shipping.

Crossing was only half the job. When the upgrade merged, the framework was new but the patterns were not: no TypeScript, no unit tests, components pulling fields from mixins, and the old Vuex store running beside Pinia.

What I built

The upgrade, 2025

A teammate led the upgrade on its own branch, converting a page or area at a time while main kept moving. I joined in late July as one of five engineers on it. My areas were projects, account and sign-up, the specification tools, schedules, comments and notifications, and most of my work was getting their pages working under Vue 3 and Nuxt 3.

Some of that was mechanical, like Vue 3’s v-model and event conventions. Forms took more thought. I moved the shared inputs and the forms I touched from vee-validate 3’s wrapper components to version 4’s composition API, and popovers to floating-vue.

The cleanup, 2026

After a teammate added TypeScript and unit tests, the frontend team ran the cleanup as bundles grouped by kind of file. Mine typed plugins and the API route modules of five feature areas, replaced several mixins with composables and moved two pieces of state from Vuex to Pinia. In May I typed and tested three more component sets, including notifications.

My bundles followed roughly the same loop: search for references, delete what nothing uses, convert what’s live, add unit tests, then run the type checker, lint on the changed files and the unit suite.

The header navigation shows the difference: its menu used to pass through a mixin and a global Vuex module that another component filled. After my bundle, one chain of typed calls runs from the navigation bar to the API client, and a Pinia store loads the menu once.

How the header navigation reached its data, before and after the cleanupBefore: a component used fields merged in from a mixin; the mixin read the menu from a global Vuex module by string key; another component fetched the menu and wrote it into that module. After: the component declares what it uses by calling a typed composable from setup(); the composable reads a Pinia store; the store loads the menu once through a typed, tested API client.BEFOREAFTERComponentundeclared fieldsmerged inMixinuntyped JavaScriptreads by keyGlobal Vuex moduleone module, many jobswritesAnother componentfetches the menuComponentdeclares what it usesComposabletyped return valuePinia storeloads the menu onceAPI clienttyped and testedcallsreadsfetches
The header navigation’s path to its menu when the upgrade merged (left) and after my cleanup bundle (right).

Decisions

Decision

Let each shared input register itself with the form.

Why
The first converted forms wired every field by hand: define it, bind its value, pass its error back. Starting with the new-project form, I moved that wiring into the shared text input and select, which register with vee-validate under their own name, then applied it to login and other forms. A form now declares one schema; each input brings its own value, error and blur handling.
Cost
My first version always handed the form an empty string as a starting value, which overrode an edit form’s starting values; a teammate later changed it so an input supplies one only when given one.

Decision

Type the API client without changing how anyone calls it.

Why
About 280 files called the API client, and dozens of calls still passed query parameters shaped for the HTTP library the upgrade removed. I replaced the JavaScript plugin with a small typed one over a tested client that keeps that convention, so callers written in TypeScript are checked. Once a teammate added types generated from the API’s schema, I made request bodies check against them.
Cost
It leaves a compatibility layer to remove later, with a comment saying why. And types can look strict while checking nothing: my first request-body type ended in a union with unknown, which accepts anything. I caught it before merge.

Decision

Swap one mixin at a time for a composable called from setup().

Why
A mixin merges fields into a component that never declares them, so a reader can’t tell where they come from, and plain JavaScript checks nothing. A composable is a typed import with private internals. Calling it from setup() moves one dependency without rewriting the Options API component around it.
Cost
Moving code moves timing: my first header change started the menu request only after two unrelated requests had finished, which review caught before merge. Components also stay half-migrated; the navigation bar kept two other mixins.

What I’d change

I’d put types and tests in place before a framework switch, not after. While fixing the notification panel on the upgrade branch, I left a watcher reading .value from a plain boolean prop, so the load it guarded never ran. With no types or tests, nothing flagged it until May 2026, when I typed and tested the notification components and found two older slips of mine as well: a component writing to its own prop, and a busy flag switched on and off in one call.

Outcome

The upgrade merged on November 6, 2025, after almost eight months on its branch, while the product kept shipping. My cleanup merged in April and May 2026: a typed API client, several mixins replaced by typed composables and two pieces of state moved to Pinia, with unit tests. Some components stay half-migrated. For any framework move I’d repeat the two steps: cross with the old patterns running, then remove them in small bundles, deleting before converting.

What’s left out

Acelab’s code and data are private, so the diagrams are redrawn and any interactive demo is a rebuild with made-up data. Names of colleagues, customers and internal tools are left out on purpose. I’m glad to go deeper on any of it in an interview.