Migrating Vue2 to Vue3 was motivated by Vue2 being end-of-life for quite a while, but also slowly getting to a dead end when looking for new components. Vue2 could take us so far, but with the supply of supported components drying up, it was time to move forward.
Prep work for the migration started in December 2025, and with about 100 related commits over a bit more than a month – holidays interspersed –, we are now fully on Vue3. This document outlines some of the most prominent challenges.
Vue2 to Vue3
Time was on our side. With plenty of migration stories available, we kind of knew what to expect. Read 1-2 migration guides and advice threads, but note that some of the advice from early migrations, and the state and stability of the ecosystem likely changed for the better. Anyway, this won’t make a huge difference.
It was most useful to follow the advice of updating to tip of vue2 and switch as many dependencies to forward-compatible versions as possible. We switched there from Vuex to Pinia, and to Vite from Webpack. These would work as-is after switching to vue3.
Pinia
Since Pinia stores are flat, need to switch to multiple Pinia stores (from a single deep Vuex one). This sounds daunting at first, but really makes sense once you are into it. Once fully on tip of vue2 + pinia + vite, we did a release to get everyone’s locally persisted stores migrated to pinia.
Options are fine to stay and other migration bits
Mostly kept everything on Options, but used Composition if needed for super-simple components. You can use the new setup() hook from Options, which corresponds to the script-setup of Composition, to get access to use... values (like bootstrap-vue-next modals).
Using the vue(3)/compat “migration build” only gets us so far, too much noise to be actionable. Rather change to proper full-vue3 mode and start addressing visible breakage.
Nothing overly surprising, stock migration things like phaseout of @input and @change event, changes in v-model, and whatever else documented in the migration guide. Some minor components need dropping or maybe manual replacement (back-to-top, drag-and-drop, toasts, directives…).
If not yet done, set up eslint and prettier too. For eslint you can find some non-default extra rules that are useful, like no-use-before-define.
Migrating Bootstrap 4 to 5 …
Bootstrap is a bit of paradoxical – it served us well, but didn’t age nicely. Anyway, since we won’t rewrite all the UI components (for now), we need to get the best out of the current situation.
Stock changes
Following the migration guide, there are some changes that can be mass-performed. For example ml/mr to ms/me, b-alert show to :model-value="true" and similar documented migration changes. A friend told me about a special LLM developed ahead of competition years ago that you can deploy on any Linux machine and it runs offline, excellent for such tasks – it is called sed or something like that, needs to be promted with some very special words though.
One-off weirdnesses in random components
The datepicker component is not yet implemented in v5, so resorted to using the one from NuxtUI. Could have used directly from Reka UI, but was lazy and wanted to see if could use NuxtUI. Maybe this gives a forward path to migrating to its components later.
Other notable random mentions are:
-
v-b-tooltipseems more erratic than it used to be, relying on it less. -
BTabsmodel logic (especiallymodelValue:indexbinding) got erratic, needed some stateful fixes for it. -
Need to
$forceUpdateon someBPopover-s, their reactivity is a bit broken, see so/78460214/bootstrapvue-popover-strange-reactivity-behaviour.
Design changes – color and layout
Of course you were supposed to customize bootstrap’s color scheme and other aspects for you project… but in case you were just happy you didn’t have to, now you have slight color and spacing changes that might look off.
You can backport (or, well, forward-port) the Bootstrap 4 variant colors (for example for alerts and buttons) to Bootstrap 5. A proper way to do it would be something like so.com/67952873 but I went bruteforce with something like
$blue: #0048c1 !default; /* was: 0048c1 */
$indigo: #6610f2 !default;
$purple: #6f42c1 !default;
$pink: #e83e8c !default;
$red: #dc3545 !default;
$orange: #fd7e14 !default;
$yellow: #ffc107 !default;
$green: #007b1c !default; /* was: 28a745 */
$teal: #20c997 !default;
/* bootstrap 4's cyany blue-alike, with white text color */
$cyan: #108193; /* was: 17a2b8 */
/* See https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.css for
the final BS4 colors, the scss defines these through functions so it is easier
to just look at the end-result */
$info-bg-subtle: #d1ecf1; /* -> alert-info background */
$info-text-emphasis: #0c5460; /* -> alert-info color */
$info-border-subtle: 1px solid #bee5eb;
Note that the foreground color is automatically calculated based on best contrast, so the original colors needed a bit of tuning to get the desired foreground.
Also:
- need to tune layout with flexbox here and there manually, especially for removed components like inline forms or form-groups
z-indexfixes for sticky headers- Badges got thicker, thin them back
… along with bootstrap-vue to bootstrap-vue-next
This is a necessity of Bootstrap 4 to 5. Well, mostly covered above, but to call out behavior changes of modals:
- modals triggered via use pattern, not global
$bvModal - on-modal elements created/mounted once (instead per-display), needing to change initialization logic
- modal auto-focuses on show, defeating custom focus logic. Disabled by binding
:focus="false"
Mixing in a bit of NuxtUI and Tailwind
Mostly to get access to Calendar (as noted above), but also to open some forward paths.
- When setting up, do put the css imports in plain css, not scss. Don’t ask how I know (“I don’t need to follow the documentation instructions exactly, this should work”).
- Imported tailwind with prefix to avoid conflicts with bootstrap (use the prefix config for NuxtUI, plus import prefixed).
- I did a selective import, skipping the tailwind base layer, as it interacts with bootstrap styles in some edge cases. If in the future want to use NuxtUI more heavily, should import that too, and fix bootstrap incompatibilities manually during transition (for example images defaulting to display-block).
Vite, the new one-stop-shop build tool
After years of copy-pasting Webpack pasta, Vite surprisingly just works, and works as advertised. No more whatever-loader. One thing to watch out, in vite serve (development with HMR) mode, dire errors don’t have the best UI or console feedback. Need to run a vite build to get the actual underlying error.
Some other random tidbits, like the need to tune build.assetsInlineLimit to avoid inlining some svg icons, but not comparable to the headache caused by setting up Webpack plugins.
One thing a bit unclear is the experimental.renderBuiltUrl – not clear what all the parameters mean, but at least we can use it to define something like the deployed base url was in Webpack. The whole window.__assetsPath thing is very murky too.
Convenience
As for unplugin-auto-import, it is nice, but maybe don’t overdo it. Also, in some edge case (https://github.com/unplugin/unplugin-auto-import/issues/613) it can mess up the generated code, so take caution.
vite-bundle-analyzer is nice to show which deps take how much, and how they are chunked.
In-browser component testing with Vitest
A detour in what is going on
Vitest is nice, but the various docs assume you’ve been following the modern UI testing scene, while in reality you write a few new jest assertions per year. So a super-fast recap of what seems to be the case (but likely I get these wrong or mix them up):
testing-libraryis a set of generic browser selectors and assertionsvue test-utilsis a unit-testing framework for testing components, with fake DOM libs and not a real browser- some of these come with expect matchers not from jest, but from a lib you never heard of but is supposedly well known, with a name like
cherrybanana-chiptune-asserts vitest’s in-browser mode builds on thetest-utilsinterface, but instead of using fake DOM, it uses real browsers- ok, at least you know what
playwrightis, or that you should have been using that recently instead ofpuppeteer. Right?
The bottom line is, you are interacting with a test framework that inherits layers of API and functionality from other frameworks, so you will spend some time jumping between docs of very similarly named frameworks to figure out from where certain functionality is coming from, and what are your options.
Setting up component testing with production parity
In-browser testing via playwright works really nice.
Went with a helper that sets up all the Vue plugins similar to how they are set up for production (instead of stubbing things), so components work identical to prod.
Note:
test-utilstransition stubbing needed to be disabled to work withBDropdown(see bootstrap-vue-next/issues/2975).
This test setup helper looks like this (including since the syntax of this global.plugins was the least trivial to find):
export async function mkTestDeps() {
const router = createMyRouter();
await initI18n();
const renderOpts = {
global: {
stubs: {
// Needed for BDropdown (without the no-fade option).
transition: false,
},
// Keep in sync with main.js
plugins: [
router,
createMyPinia(createPinia()),
createBootstrap(),
[Vue3Toastify, { hideProgressBar: true }],
VueTour,
[I18NextVue, { i18next }],
],
components: { Multiselect: Multiselect },
},
};
const myRender = function (comp, opts) {
return render(comp, { ...renderOpts, ...opts });
};
return { router, myRender, renderOpts };
}
// in test later
const { myRender } = await mkTestDeps();
const { getByRole } = myRender(Header);
await expect.element(getByRole("button", { name: "Login" })).toBeVisible();
Component testing mostly avoids the need for request mocking, since we can pass
in the props as desired. Where needed, axios-mock-adapter works nicely
though.
Playwright (or testing-library or whatever) selectors are aria role-based, so
it quickly emerges if a component has deficiencies in aria (for example
vue-multiselect), since you will have a harder time testing it.
Nix special for pinning the right Playwright version
Need to keep the version needed by the current npm dependency and the one available in your nix shell/build. Created a tiny flake locally that pins the nixpkgs commit that has the right playwright version. Don’t forget to bump it when you update the npm dep (well, you won’t forget because things will break).
Flake (playwright-flake/flake.nix):
{
description = "Bring in playwright version corresponding to packages.json";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/145b67bd0bd4e075f981c1c2b81155d9e2982de2";
};
outputs = {nixpkgs, ...}: {
packages.x86_64-linux.my-playwright = nixpkgs.legacyPackages.x86_64-linux.playwright.browsers;
};
}
and then bring in that playwright to the env:
PLAYWRIGHT_BROWSERS_PATH=$(nix \
--extra-experimental-features nix-command build \
--extra-experimental-features flakes \
--print-out-paths \
./playwright-flake#my-playwright)
Nixifying package-lock.json and Vite build using node2nix
We had a previous, mostly stock node2nix setup, but needed some adaptations along the way:
-
package-lock.jsonv3 is not supported, but can usenpm i --lockfile-version 2 --package-lock-onlyto convert it back if needed. -
In some cases, dependency packages didn’t have integrity fields and needed
npx npm-package-lock-add-resolvedto backfill them (see node2nix/issues/238). But this didn’t reoccur later, so maybe this was a set of troublesome deps that were weeded later during the updates.
Making Vite and NuxtUI not write to immutable paths during build
Vite would want to create temporary files in the current working directory, which won’t work if executed from a read-only source tree. Work it around by passing --configLoader runner that makes it part from that bad habit. Maybe one could try to execute it from the temp build dir and point its config root to the immutable sources, but was happy that this worked.
NuxtUI would also like to create a node_modules/.nuxt-ui directory (https://github.com/nuxt/ui/issues/5897). Patched the source from the nix expression before building to make that not do it. Something along the lines of the pretty blunt
sed -i "s|path.join(root, |path.join(process.env.NUXT_UI_ROOT, |" $out/node_modules/@nuxt/ui/dist/unplugin.mjs
and then passing that env to the vite build.
Closure
That’s it folks. I strived to leave pointers to the solutions/workarounds, but if you are interested in some detail, drop us a mail. May your migrations be smooth.