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.
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.