Vue 3 web components as a bridge for migrating Nuxt 2 to Vue 3
How Vue 3 web components, built with defineCustomElement, let a Nuxt 2 host and Vue 3 islands share one page through DOM attributes and CustomEvents, what you give up, and when the approach fails.
The "incremental migration" promise of any framework version-bump only works if the new and the old can share a page. For Nuxt 2 to Vue 3, the bridge is Vue 3 web components: ship Vue 3 surfaces as native custom elements, mounted inside the Nuxt 2 host, so the runtime cost of a rewrite gets paid one component at a time. This is the deep dive on the bridge layer that unlocked the WegoPro Nuxt migration.
The shape
Author the new feature as a normal Vue 3 component. Run it through defineCustomElement and register the class with customElements.define. The result is a real HTMLElement. Mount it inside any Nuxt 2 template like a normal tag. Vue 3 boots its own reactive runtime inside the element's shadow root. Vue 2's runtime keeps owning the host. Both heaps coexist on the same page, talking through DOM attributes and CustomEvent objects.
What you give up
Slots cross the boundary as plain DOM - they aren't Vue slots once they exit the custom element, so you lose scoped-slot semantics. Provide/inject doesn't cross either. Vue 2 host components can't pass reactive props directly into a Vue 3 custom element; props arrive as attribute strings unless you reach the element through a ref and assign properties imperatively. Most state in practice lives inside the Vue 3 island, fed by the URL, a shared store, or a one-shot init payload.
What you keep
The Nuxt 2 router stays the source of truth. SSR keeps working for the host - the custom element renders as an opaque tag server-side and hydrates on the client. Build pipelines stay separate; the Vue 3 bundle is published as a static asset the host loads on demand. You migrate one feature at a time, with no single cutover. The day you finish migrating, the bridge can be deleted.
When it fails
Tightly-coupled UI that crosses the boundary every render. Drag-and-drop targets that span host and island. Forms whose validation lives outside the island. If you can't draw a clean line around the island, the bridge is more expensive than the cleanup it was meant to defer. Pick boundaries that match data ownership: the island owns its state and DOM, the host owns layout and navigation.
What about reactive props
The honest answer: there's no good direct path. Attribute values are always strings, so anything richer than a primitive has to be JSON-encoded by the host and parsed by the island, or assigned as a JS property via a template ref, which breaks declarative templates. Outbound goes the other way: dispatch a CustomEvent from the island, listen on the host. Treat the API surface like a network boundary and accept the cost of serializing everything that crosses it.