instead of rebuilding everything
A big-bang rewrite fails more often than it succeeds. The better strategy: introduce Vue step by step into existing PHP monoliths, Twig templates, and jQuery applications, island by island, without interrupting ongoing operations.
Table of Contents
- 1. Why a rewrite is not a solution
- 2. The island strategy: introducing Vue incrementally
- 3. Mount patterns: createApp for multiple widgets
- 4. Transferring data from PHP and Twig into Vue
- 5. jQuery coexistence: sharing events and the DOM
- 6. Vue as custom elements: framework-agnostic use
- 7. State management between Vue islands
- 8. Build integration with Vite in existing asset pipelines
- 9. Migration strategies compared
- 10. Summary
- 11. FAQ
1. Why a rewrite is not a solution
Vue legacy integration begins with recognizing that a full rewrite is a substantial risk in most projects. Joel Spolsky called it "the single worst strategic mistake that any software company can make": throwing everything away and starting from scratch. The reason is that an existing application holds years of accumulated domain knowledge, bug fixes, edge cases, special-case logic for certain customers, that gets rediscovered in the new system only after costly incidents. Vue legacy integration instead means preserving that knowledge and modernizing the frontend layer step by step.
There is also an economic angle: while a rewrite project is underway, the existing system still has to be maintained. Running two codebases in parallel costs more than running one. Bugs get fixed twice, features get built twice, and developers are split between old and new code. Vue legacy integration sidesteps this problem, because the existing codebase remains the foundation and Vue components are added as self-contained units without touching the existing logic.
The third motivation is risk distribution. A Vue legacy integration can be paused at any point without leaving the application in a half-finished state. Every completed Vue island delivers immediate value in production. The team learns the new approach gradually, instead of having to go live all at once after a long rewrite phase. In regulated industries, under heavy traffic load, and in teams with uneven skill levels, this incremental approach is the only responsible migration path.
2. The island strategy: introducing Vue incrementally
The core concept of Vue legacy integration is the island architecture: individual, well-bounded areas of the existing application are replaced with Vue components while the rest of the page stays untouched. The server keeps rendering the HTML skeleton, and Vue mounts itself into designated container elements. A typical first island is a complex form, an interactive search, or a cart widget, areas where jQuery spaghetti code hurts the most and where a Vue component delivers the greatest immediate value.
Choosing the first islands for a Vue legacy integration should follow three criteria: how often the area changes, how complex the existing code is, and how isolatable it is from the rest of the page. Areas that rarely change and are tightly intertwined with the rest of the page are poor first candidates. Areas that frequently get new features, are hard to test, and can be clearly bounded are ideal. A search widget that talks to a REST API and has no direct DOM dependency on other page elements is an ideal starting point for Vue legacy integration.
3. Mount patterns: createApp for multiple widgets
In Vue legacy integration scenarios, createApp is the central function. Unlike Vue 2, where there was only one global Vue instance, Vue 3 lets you mount multiple independent applications on the same page. This is essential for legacy integration: every Vue island is its own createApp instance with its own plugin system, its own provide/inject context, and its own state. Plugins registered in one instance do not affect other instances.
The initialization script for a Vue legacy integration scans, on the DOMContentLoaded event, all elements carrying a defined data-schema attribute and mounts the matching Vue component. This pattern lets you place multiple instances of the same component on a page without the server needing to know which framework renders the component. The data arrives as JSON in a data attribute; the Vue component reads it on mount and renders itself entirely on the client.
// src/legacy-init.js
// Entry point for Vue integration into legacy PHP/Twig pages
// Include via: <script src="/dist/legacy-bundle.js"></script>
import { createApp } from 'vue'
import SearchWidget from './components/SearchWidget.vue'
import CartWidget from './components/CartWidget.vue'
import ProductGallery from './components/ProductGallery.vue'
// Registry maps data-vue-component attribute values to Vue components
const COMPONENT_REGISTRY = {
'search-widget': SearchWidget,
'cart-widget': CartWidget,
'product-gallery': ProductGallery,
}
// Mount all Vue islands found on the current page
function mountVueIslands() {
const islands = document.querySelectorAll('[data-vue-component]')
islands.forEach((el) => {
const componentName = el.dataset.vueComponent
if (!COMPONENT_REGISTRY[componentName]) {
console.warn(`[Vue Legacy] Unknown component: ${componentName}`)
return
}
// Pass server-rendered JSON as props via data-props attribute
let props = {}
if (el.dataset.props) {
try {
props = JSON.parse(el.dataset.props)
} catch (e) {
console.error(`[Vue Legacy] Invalid props JSON for ${componentName}`, e)
}
}
const app = createApp(COMPONENT_REGISTRY[componentName], props)
// Register shared plugins only once per island
app.use(router)
app.mount(el)
})
}
// Wait for DOM, then mount
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountVueIslands)
} else {
mountVueIslands()
}
4. Transferring data from PHP and Twig into Vue
Transferring data from the server to Vue components is one of the first problems that has to be solved in a Vue legacy integration. The simplest approach: PHP or Twig renders the initial data as JSON in a data attribute on the mount element, or in a hidden script tag with type="application/json". Vue reads that JSON on mount, uses it as initial state, and then fetches more current data via the API. This avoids an extra API call for the first render and is especially relevant for SEO-critical data such as product names and prices.
For more complex Vue legacy integration scenarios, a global JavaScript object that PHP populates and Vue reads on startup works well. Twig renders window.__INITIAL_STATE__ = {{ initial_state | json_encode | raw }}; in the head, and the Vue entry script imports that object and provides it as a value to all Vue islands. This decouples data hand-off from the mount element and lets you pass whole-page context data, such as the logged-in user, currency, or locale, to all Vue components without having to populate every mount container individually.
5. jQuery coexistence: sharing events and the DOM
In many Vue legacy integration projects, jQuery is still active and manages parts of the DOM that exist alongside Vue islands. The golden rule: Vue and jQuery must never manage the same DOM element. Vue takes full control of its mount element and all its children. jQuery must not read from or write to that element except through a defined interface. The safest approach: Vue islands get their own container elements that jQuery does not know about and never touches.
Communication between jQuery and Vue islands in a Vue legacy integration runs through native browser events. jQuery fires document.dispatchEvent(new CustomEvent('cart:updated', { detail: { count: 3 } })), and Vue components listen for that event in an onMounted hook. Conversely, Vue fires custom events that jQuery code can receive. This pattern is explicit, debuggable in the browser DevTools event monitor, and creates no direct dependency between the technologies. It lets you replace jQuery code and Vue islands independently of each other without changing the interface.
// Communication bridge between jQuery legacy code and Vue islands
// jQuery fires events, Vue listens, and vice versa, via native CustomEvents
// --- JQUERY SIDE (legacy code, untouched) ---
// jQuery triggers an event when cart changes (existing code, no modification needed)
// $(document).trigger('cart:updated', [{ itemCount: 3, total: '49.90 EUR' }])
// Translated to native CustomEvent for compatibility:
$(document).on('cart:updated', function (event, data) {
document.dispatchEvent(new CustomEvent('vue:cart-updated', { detail: data }))
})
// --- VUE SIDE (CartWidget.vue) ---
// Listen for legacy jQuery events from the DOM
import { ref, onMounted, onUnmounted } from 'vue'
export function useCartBridge() {
const itemCount = ref(0)
const total = ref('')
function handleCartUpdate(event) {
itemCount.value = event.detail.itemCount
total.value = event.detail.total
}
onMounted(() => {
document.addEventListener('vue:cart-updated', handleCartUpdate)
})
onUnmounted(() => {
document.removeEventListener('vue:cart-updated', handleCartUpdate)
})
// Vue fires events back to jQuery when Vue-side actions happen
function notifyLegacy(eventName, detail) {
document.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true }))
}
return { itemCount, total, notifyLegacy }
}
6. Vue as custom elements: framework-agnostic use
Vue 3 natively supports compiling components into web components / custom elements. This is particularly interesting for Vue legacy integration in polyglot environments: a Vue component packaged as a custom element works in any HTML context, in PHP templates, in Twig, in server-rendered Magento HTML, without the host page needing to know anything about Vue. The custom element behaves like a native HTML element, receives attributes and properties, and fires events.
The main advantage of custom elements in Vue legacy integration is complete decoupling: the host page references a JavaScript bundle and uses an HTML tag. Whether the implementation behind it is Vue, React, or vanilla JavaScript is irrelevant to the host page. This makes it possible to swap out the implementation over several years without ever touching the templates. Custom elements do have limitations, though: SSR is more complicated, styling requires Shadow DOM or CSS custom properties, and prop passing only works with JSON-serializable values.
7. State management between Vue islands
Multiple Vue islands on a page occasionally need to share state, for instance the cart counter in the header should update when a product card changes the cart. In a single-page application that is trivial, because all components live in the same Vuex or Pinia store. In a Vue legacy integration with separate createApp instances, the islands do not share a store by default. The solution: a shared Pinia store instantiated as a singleton outside all createApp instances.
The pattern for shared state in Vue legacy integration: a createPinia() instance is created in the entry script and passed to every createApp instance as a plugin. Because Pinia stores behave as singletons, every Vue island that uses Pinia shares the same store state. Changes in one island are immediately visible in all the others. This pattern also works with a reactive mitt event bus as a lightweight alternative when no full state store is needed and only events need to be exchanged between islands.
8. Build integration with Vite in existing asset pipelines
Build integration is, in many Vue legacy integration projects, the biggest technical challenge. Existing PHP applications often use Webpack Mix, Gulp, Grunt, or simple concat scripts for assets. Vite, as a modern build tool for Vue, does not automatically fit into these pipelines. The pragmatic approach: Vite runs as a separate build step that writes the finished bundle into the existing public directory. The existing asset pipeline is left untouched; it just loads the Vite bundle as a regular JavaScript file.
For configuration in a Vue legacy integration, the Vite configuration needs its build.lib mode or its build.rollupOptions.input pointed at the legacy entry point. The result is a single JavaScript bundle that contains all Vue components and initializes itself. The content hash in the filename can be used for cache busting. The bundle gets included via a Twig or PHP partial that reads the build manifest and renders the current filename, a pattern that has become equally established in Magento, Symfony, and Laravel.
9. Migration strategies compared
There are several strategies for Vue legacy integration with different effort-to-benefit profiles. The choice depends on team size, the complexity of the existing application, and the resources available.
| Strategy | Effort | Risk | Best suited for |
|---|---|---|---|
| Island integration | Low (widget by widget) | Minimal | Monoliths, live operation |
| Custom elements | Medium | Minimal | Multi-framework environments |
| Strangler fig pattern | High | Medium | Medium-term full migration |
| Micro-frontends | Very high | Medium to high | Large teams, monorepos |
| Big-bang rewrite | Extremely high | Very high | Only if a total replacement is unavoidable |
The strangler fig pattern is a proven approach for Vue legacy integration over the medium term. Routes are switched over step by step to a new Vue SPA, while the legacy server still serves the remaining routes. A reverse proxy (nginx or Caddy) decides which requests go to the new SPA and which go to the old server. That way, migration can proceed page by page, and a "feature freeze" is never necessary. After a few months, enough pages have been migrated that the legacy server can be shut down.
Mironsoft
Vue Legacy Integration · Migration · Frontend Architecture
Modernizing your legacy frontend without the risk?
We analyze existing PHP, Twig, and jQuery applications and develop a step-by-step Vue integration strategy that does not interrupt ongoing operations.
Analysis
Evaluate the existing application, identify integration points, and define a migration path
Implementation
Implement Vue islands, set up build integration, and build jQuery bridges
Handover
Onboard the team, plus documentation and patterns for independent further development
10. Summary
Vue legacy integration is not an either-or choice between old and new stacks, but a controlled transition that respects ongoing operations. The island pattern with multiple createApp instances lets you add Vue components without any change to existing PHP or Twig templates. Data flows from the server to Vue via JSON attributes and global JavaScript objects. jQuery and Vue coexist through native browser events, without sharing direct DOM access. Custom elements are the strongest decoupling strategy for polyglot environments.
The biggest mistake in a Vue legacy integration project is expanding the scope too early and drifting into a de facto rewrite. Clear boundaries, which parts of the page are Vue's responsibility and which are not, plus consistent event-based communication, keep the integration maintainable. With every jQuery widget replaced, the team grows into Vue without ever risking the whole operation.
Vue Legacy Integration, the essentials at a glance
Island pattern
Multiple createApp instances on one page, each island is isolated, with its own plugins and its own provide/inject context.
Data bridging
PHP/Twig renders JSON into data attributes or window.__INITIAL_STATE__. Vue reads it on mount, no extra API call for initial data.
jQuery coexistence
Native CustomEvents as the bridge, jQuery and Vue never manage the same DOM element, they only communicate via events.
Shared state
A Pinia singleton outside all createApp instances, all Vue islands share the same store without direct coupling.