The two front-end upgrades a Craft CMS (or any Twig/PHP) agency keeps meeting: an asset pipeline still on Laravel Mix or raw Webpack, and widgets still on Vue 2. Both are frozen - Mix's last release was 6.0.49 in June 2022 and Laravel calls it "a legacy package that is no longer actively maintained"; Vue 2 reached end of life on 31 December 2023. This skill is the playbook for moving a server-rendered site to Vite 8 + craft-vite and Vue 3, or for deciding that a widget should not be Vue at all.
Version facts verified as of 2026-10-05 against npm, Packagist, vite.dev, v3-migration.vuejs.org and nystudio107.com/docs/vite. Each reference cites its sources.
Start here: what does this repo need?
| You find | Do | Read |
|---|---|---|
webpack.mix.js, mix-manifest.json, mix() in Twig |
Replace Mix with Vite + craft-vite | mix-webpack-to-vite.md, then craft-vite-twig.md |
A hand-written webpack.config.js |
Same move; translate loaders and plugins one by one | mix-webpack-to-vite.md |
| craft-vite already, but the dev server fails behind DDEV | CORS / allowedHosts / one port in four places |
craft-vite-twig.md |
"vue": "^2 in package.json |
Decide per widget: migrate or replace (table below) | this file, then vue2-to-vue3.md |
| Mix and Vue 2 | Bundler first, framework second - never one deploy | Sequencing |
vuex, vue-router 3, Vue 2-only plugins |
Pinia; check every plugin for a Vue 3 path before estimating | vue-state-and-plugins.md |
Vue templates written in Twig (inline-template, in-DOM) |
Convert to SFC islands; mind the compiler build and template injection | vue-islands-in-twig.md |
| Vue 3 + Vite already | Out of scope here - steady-state Vue is vue-ops, steady-state Craft is craftcms-ops |
- |
Migrate Vue, or replace it?
Most agency Vue 2 code is not an app; it is a handful of widgets. Migrating a widget that a dozen lines of Alpine would do costs more than replacing it. Decide per island, before estimating the migration.
| The widget is... | Verdict | Why |
|---|---|---|
| Toggle, accordion, tabs, mobile nav, show/hide | Native HTML or Alpine.js | <details>, <dialog>, popover cover much of it with zero JS; the rest is a boolean in x-data written straight into Twig |
| A form with validation and a submit spinner | Alpine + native constraint validation | No component tree, no store; Alpine keeps the markup in Twig where editors expect it |
| A thin wrapper around a framework-free library (slider, date picker, map, lightbox) | Vanilla JS or Alpine calling the library directly | The Vue wrapper was the only Vue 2-specific part |
| Renders server data and never changes it | Delete it; render in Twig | It's a template, not an app |
| Filtering/sorting a list with derived counts, several child components | Vue 3 | Computed state and components earn their weight |
| Several islands sharing state (cart, saved items, auth) | Vue 3 + Pinia | A shared store across apps is what Vue + Pinia does well |
| Routing inside a section (multi-step tool, dashboard) | Vue 3 (+ vue-router) | Alpine has no answer for this |
| Built on a Vue 2 UI kit with no Vue 3 release | Rewrite budget, Vue 3 or Alpine, decided by the rows above | The kit, not Vue, is the migration |
Rules of thumb for the grey zone: under ~150 lines with one level of state, Alpine wins; any use of Vuex, a router, or more than two child components, Vue 3 wins. A site that ends up with both is normal - see the Alpine coexistence rules in vue-islands-in-twig.md.
Sequencing both upgrades
Each numbered step is a separate deploy with its own parity check. Combining the bundler swap with the framework swap is the classic mistake: when a page breaks you cannot tell which change did it.
0. Inventory rg the repo (below); list page types that render JS widgets
1. Vue 2.7.16 if on Vue 2.6 - backports Composition API + <script setup>
2. Mix -> Vite still on Vue 2: Vite 7.3 + @vitejs/plugin-vue2
(plugin-vue2's peer range ends at vite ^7.0.0 as of 2026-10-05;
Vite 7.3 still gets important fixes + security patches)
3. Replace widgets the table above says shouldn't be Vue -> Alpine/vanilla
4. Vuex -> Pinia 2 (optional, still on Vue 2.7; Pinia 3+ dropped Vue 2)
5. Vue 3 @vue/compat on Vite 8 + @vitejs/plugin-vue; burn down warnings
6. Drop compat then Pinia 4, then remove @vitejs/plugin-legacy if analytics allow
No Vue on the site? Steps 1, 3-6 vanish: go straight to Vite 8.
If Vue 2 must die first (a security finding, a hosting rule), the order flips: Vue 3
via @vue/compat on Webpack is supported by the migration guide, then Vite. It is
the slower path - two configurations of Vue 3 tooling instead of one.
# Step 0 - inventory in one pass
fd -H "webpack.mix.js|webpack.config.js|mix-manifest.json|vite.config" -E node_modules
jq '{vue:.dependencies.vue, mix:.devDependencies["laravel-mix"], webpack:.devDependencies.webpack, vite:.devDependencies.vite, vuex:.dependencies.vuex}' package.json
rg -n "mix\(|craft\.vite|data-vue-island|inline-template|new Vue\(" templates/ src/ | head -50
Cutover checklist: Mix/Webpack to Vite
Work top to bottom; each line links to the detail.
- Branch from a green build; record a parity baseline - screenshots of every
page type + the network panel's JS/CSS list on the Mix build, plus lab LCP/CLS/TBT
for the key templates (median of 5 runs, method in
web-perf-ops) -
npm pkg set type="module"(or name configs.mjs/.cjsexplicitly) -
vite.config.js:base,build.manifest,outDir: web/dist, entries inbuild.rolldownOptions.input(config) - Every
webpack.mix.jscall mapped (translation table) - Source rewrites:
require,require.context,.vueextensions,process.env,MIX_toVITE_, Sass~imports (rewrites) -
postcss.config.js+tailwind.config.jsload;contentglobs still findtemplates/**/*.twig- stay on Tailwind v3 for this deploy -
composer require nystudio107/craft-vite(5.x Craft 5, 4.x Craft 4);config/vite.phpwithmanifestPath=@webroot/dist/.vite/manifest.json - DDEV: the same port in
web_extra_exposed_ports,server.port,server.origin,devServerPublic;cors+allowedHostsfor.ddev.site(DDEV) - Twig: every
mix()/ hard-coded/js/,/css/tag replaced withcraft.vite.script(...); main CSS withasyncCss = falseunless critical CSS exists - Inline scripts that used bundle globals at parse time fixed (module scripts are deferred) (why)
- Twig-referenced images resolved via
craft.vite.asset()or left inweb/ - Legacy build: decided from analytics,
@vitejs/plugin-legacyonly if needed (legacy) - HMR works for CSS + JS; Twig edits reload (
vite-plugin-restart) -
vite buildin CI/deploy;web/dist/gitignored; no hashed files committed - Parity check against the baseline: every page type, fonts, icons, console clean,
and the lab metrics - async CSS and
modulepreloadmove LCP and CLS (web-perf-opshas the LCP/CLS fixes) - Deploy keeps the previous build until full-page caches are purged
- Delete
webpack.mix.js,mix-manifest.json, oldweb/js+web/css,laravel-mixand webpack-only devDependencies - in a follow-up commit
Vue 2 to 3 at a glance
The full workflow, the breaking-change table and testing live in vue2-to-vue3.md. The shape:
- Alias
vueto@vue/compat, setcompatConfig: { MODE: 2 }in@vitejs/plugin-vue. - Port
new Vue({ el })tocreateApp(...).mount(el)- per island. - Fix compile errors, rename transition classes, upgrade Vuex/router, then runtime
warnings one compat ID at a time (
compatConfig: { MODE: 3 }per clean component). - Global
MODE: 3, zero warnings, remove compat.
Three Twig-site landmines worth knowing before you start:
inline-templateis removed - Twig-authored templates need a scoped slot, a<script type="text/html">template, or conversion to an SFC island.- Removing compat removes the template compiler.
@vue/compatresolves to the full build, plainvueto runtime-only; in-DOM islands go blank unlessvueis aliased tovue/dist/vue.esm-bundler.js. - In-DOM templates execute
{{ }}from content - client-side template injection. SFC islands with props avoid it (security).
Common gotchas
| Gotcha | Prevention |
|---|---|
| Bundler and framework upgraded in one deploy | Sequencing above; one parity check per step |
| Tailwind v4 "while we're at it" | Separate migration with its own visual diff (tailwind-ops) |
cors: true / allowedHosts: true copied from a sample |
Regex + .ddev.site; true opens the dev server to DNS rebinding |
Each island calls createPinia() |
One exported instance, app.use(pinia) everywhere |
@vitejs/plugin-vue2 on Vite 8 |
Its peer range ends at Vite 7; finish Vue 3 before Vite 8, or stay on Vite 7.3 meanwhile |
Secrets moved from MIX_ to VITE_ vars |
VITE_* is inlined into public JS - keep secrets server-side |
| Old Mix files deleted in the cutover deploy | Delete in a follow-up once nothing references them |
| Estimating Vue 3 before checking plugins | One Vue 2-only plugin can be the critical path - inventory first |
Reference files
| File | Contents | Lines |
|---|---|---|
| references/mix-webpack-to-vite.md | Mix API to Vite map, vite.config.js for Craft, source rewrites, Tailwind v3/PostCSS/Sass, legacy builds, output |
~200 |
| references/craft-vite-twig.md | craft-vite install + config/vite.php, DDEV dev server/HMR, Twig tags, asset URLs, deploy, troubleshooting |
~220 |
| references/vue2-to-vue3.md | Migration build under Vite, warning burn-down, Twig-relevant breaking changes, removing compat, testing | ~190 |
| references/vue-state-and-plugins.md | Vuex to Pinia (2 vs 4), shared store across islands, Vue 2 plugin replacement table | ~170 |
| references/vue-islands-in-twig.md | Island bootstrap, props from Twig, in-DOM templates, template injection, Alpine/Sprig/cache coexistence | ~190 |
Staleness verifier
The versions above move. scripts/check-frontend-upgrade-facts.py
guards them against silent drift; the catalogue is
assets/frontend-upgrade-facts.json.
# Structural (PR CI, no network): every catalogued package/fact is still named in the
# prose, and SKILL.md keeps a dated "as of" note.
python scripts/check-frontend-upgrade-facts.py --offline # exit 0 consistent, 10 drift
# Live (freshness job, never blocks a PR): npm + Packagist majors vs documented majors.
python scripts/check-frontend-upgrade-facts.py --live # exit 10 a major moved, 7 unreachable
A --live drift means the world moved (say, Vite 9 or Pinia 5): re-verify the affected
reference against its cited source, then bump documented_major - never just the number.
See also
| Skill | When to combine |
|---|---|
migrate-ops |
Generic upgrade strategy, rollback, codemods, other frameworks |
vue-ops |
Steady-state Vue 3: Composition API, Pinia, Vue Router, testing |
craftcms-ops |
Steady-state Craft 5: Twig, element queries, Matrix-as-entries |
ddev-ops |
DDEV itself: Node pinning and EOL majors, exposed ports and daemons, Mutagen and node_modules |
web-perf-ops |
Before/after Core Web Vitals for the cutover; LCP/INP/CLS fixes once on Vite |
tailwind-ops |
The separate Tailwind v3 to v4 migration |
playwright-ops |
Page-type smoke tests for the parity checks |
security-ops |
Reviewing in-DOM template injection and VITE_* exposure |
package-manager-ops |
The install side: one lockfile, npm ci in CI and deploy, the Node pin, node-sass to Dart Sass, Bower to npm |