Helper for mounting Vue 3 components into the DOM as custom HTML elements. This utility simplifies embedding Vue components dynamically onto specific DOM elements and facilitates the seamless integration of Vue components into non-SPA environments (e.g. legacy CMS, server-rendered pages, multi-page apps).
- Introduction
- Installation
- Usage
- Options
- Hooks
- Attributes
- Composables
- Navigation
- Manifest loader
- API
- Packages
- License
The @mindtwo/app-component package is a utility that allows you to easily mount Vue components onto specific HTML elements through native custom elements. This is particularly useful when integrating Vue into existing applications where Vue isn't controlling the entire page. The package handles the lifecycle of Vue components, including mounting, unmounting, hooks and prop forwarding from HTML attributes.
- Embedding Vue components into non-SPA applications.
- Dynamic mounting and unmounting of Vue components on demand.
- Passing props through HTML attributes.
- Lifecycle hooks for integration with surrounding code.
- Optional Shadow DOM isolation per component instance.
Install the package from the GitHub npm registry:
npm install @mindtwo/app-component --registry=https://npm.pkg.github.com/mindtwoConfigure your .npmrc to use the GitHub npm registry for the @mindtwo scope:
# .npmrc
@mindtwo:registry=https://npm.pkg.github.com/vue^3.5.17
Register the component once on application startup. AppComponent.create() defines a custom HTML element that mounts the Vue component for every matching tag found on the page.
import { AppComponent } from '@mindtwo/app-component';
import MyComponent from './components/MyComponent.vue';
AppComponent.create({
name: 'my-app-component',
component: MyComponent,
});In your HTML:
<my-app-component greeting="Hello"></my-app-component>The component is auto-mounted whenever it is connected to the DOM.
If you don't need any options you can use the shorthand:
AppComponent.create('my-app-component', MyComponent);All HTML attributes (except a few reserved names — see Attributes) are forwarded as props to the Vue component. Attribute names are converted to kebab-case:
<my-app-component some-prop="Hello, World!" user-id="42"></my-app-component><script setup>
defineProps<{
someProp: string;
userId: string;
}>();
</script>Enable Shadow DOM isolation per component:
AppComponent.create({
name: 'my-app-component',
component: MyComponent,
shadowRoot: true,
});Provide a stylesheet URL, inline CSS, or any combination of the two. Each entry is rendered into the component root as either a <link> or a <style> element:
AppComponent.create({
name: 'my-app-component',
component: MyComponent,
// A bare string is treated as a URL.
style: '/assets/my-component.css',
});
AppComponent.create({
name: 'my-app-component',
component: MyComponent,
style: [
{ url: '/assets/base.css' },
{ css: ':host { --primary: #34d399; }' },
],
});AppComponent.create() accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
required | Component name. Used for PascalCase global registration. |
component |
Component |
required | The Vue component to mount. |
elementName |
string |
kebab-case of name |
Custom element tag name. |
hookableName |
string | boolean |
undefined |
Prefix for emitted hook names. If true, uses kebab-case of name. |
shadowRoot |
boolean |
false |
If true, the component is mounted inside a Shadow DOM. |
debug |
boolean |
false |
Enables verbose logging. |
globalHooks |
boolean |
false |
If true, the component uses a shared hooks instance across all components. |
style |
StyleSpec | StyleSpec[] |
undefined |
Stylesheet(s) to inject. See Stylesheets. |
hooks |
{ [name]: fn | { callback, once }} |
undefined |
Hook callbacks to register at creation time. |
navigation |
NavigationAdapter |
undefined |
Navigation adapter (see Navigation). Enables useNavigation() and bridge.navigate(). |
Hooks let you react to the component lifecycle. Register them via the hooks option, or via bridge.on() from the global bridge object.
Available hook names:
init— fired before the AppComponent is constructed.ready— fired after the bridge is created.initialized— fired after the HTML element initializes its DOM.connected— fired when the element is added to the DOM.disconnected— fired when the element is removed from the DOM.creating/created— around Vue app creation.mounting/mounted— around Vue app mount.unmounting/unmounted— around Vue app unmount.loaded— reserved for asynchronous manifest loading (see Manifest loader).navigate— reserved for consumer-fired SPA navigation events.
Example:
AppComponent.create({
name: 'my-app-component',
component: MyComponent,
hooks: {
connected: (el) => {
console.log('element connected', el);
},
mounted: { once: true, callback: (bridge, app) => console.log('mounted', app) },
},
});You can also use custom hook names. Custom hooks are stored as "external hooks" and cleaned up automatically on unmount().
Extend the ComponentHookMap interface via module augmentation to get autocomplete and type-checking for your own hooks:
declare module '@mindtwo/app-component' {
interface ComponentHookMap {
'analytics:track': (event: string) => void;
'auth:logout': void;
}
}
bridge.on('analytics:track', (event) => {
// event is typed as string
});The generated custom element forwards HTML attributes as props. The following attribute names are reserved and not forwarded:
auto-mount— reserved, but currently auto-mount is the default behavior.- Any
data-v-*Vue scope-id attributes. data-type-*— used as type hints for the matching prop (see below).
All other attributes are converted to kebab-case keys on the Vue props object.
By default every attribute value is forwarded as a string. Add a data-type-{prop} hint to coerce the value into a richer type:
<my-app-component
count="5" data-type-count="number"
active="" data-type-active="boolean"
config='{"x":1}' data-type-config="json"
></my-app-component>Supported types: string (default), number, boolean, json. Parse failures fall back to the raw string and emit a warning.
Registers a custom element and creates the bridge. Returns Promise<AppComponentBridge> so you can keep an imperative handle without going through window. Auto-mounts on connect.
const bridge = await AppComponent.create('my-app-component', MyComponent);
bridge.on('mounted', () => console.log('ready'));For each component, a bridge instance is registered on window under the PascalCase name. You can use it to control the component after creation:
// e.g. for name "my-app-component", PascalCase: "MyAppComponent"
const bridge = window.MyAppComponent;
await bridge.mount({ extraProp: 'value' });
await bridge.unmount();
bridge.recreate();
bridge.on('mounted', () => console.log('mounted'));
bridge.off('mounted', handler);
bridge.emit('custom-event', payload);create(): Promise<void>— create the underlying Vue app.mount(props?): Promise<void>— mount and optionally merge additional props.unmount(): Promise<void>— unmount and clean up external hooks.recreate(): void— rebuild DOM and recreate the Vue app.created(): boolean— whether the Vue app has been created.getName(): string— get the PascalCase name.on(name, callback, once?)/off(name, callback)— manage hook callbacks.emit(name, ...args)/trigger(name, ...args)— emit hooks.navigate(url, action?)— host-facing entry point for the registered navigation adapter (see Navigation). No-op with a warning when no adapter is set.
Inside Vue components mounted via AppComponent.create(), the @mindtwo/app-component-composables package gives you typed access to the bridge, hooks, and root element:
import { useHooks, useAppComponent, useAppComponentRoot } from '@mindtwo/app-component-composables';
const hooks = useHooks(); // ComponentHooks
const { name, bridge, element } = useAppComponent();
const getRoot = useAppComponentRoot(); // () => ShadowRoot | HTMLElement | undefined
hooks.on('mounted', () => {
getRoot()?.appendChild(myEl);
});All three composables throw a clear error when called outside an app-component context, so misuse fails loudly rather than silently returning undefined.
App-components are often embedded into pages that already own their URL. Pass a navigation adapter to AppComponent.create({ navigation }) to give the Vue component a uniform API for reading the current URL and requesting changes, regardless of who actually controls the address bar.
Two built-in adapters ship with the core package:
| Adapter | Who owns the URL | When the Vue side calls push(url) |
How the host pushes a URL into the component |
|---|---|---|---|
createHistoryNavigationAdapter() |
The component (window.history) |
Calls history.pushState, updates currentUrl |
n/a — back/forward buttons handled via popstate |
createEventNavigationAdapter() |
The host page | Fires the navigate hook; URL bar untouched |
Host calls bridge.navigate(url) |
import { AppComponent, createHistoryNavigationAdapter } from '@mindtwo/app-component';
import MyApp from './MyApp.vue';
AppComponent.create({
name: 'my-app',
component: MyApp,
navigation: createHistoryNavigationAdapter(),
});<script setup>
import { watch } from 'vue';
import { useNavigation } from '@mindtwo/app-component-composables';
const nav = useNavigation();
watch(nav.currentUrl, (url) => console.log('pathname:', url.pathname));
</script>
<template>
<a href="#" @click.prevent="nav.push('/details')">Go to details</a>
</template>The host page tells the component about URL changes by calling bridge.navigate(url). When the embedded component wants to navigate, it calls nav.push(url) which fires the navigate hook — the host listens and decides what to do (update its own router, change the URL bar, etc.).
// in the embedded bundle
import { AppComponent, createEventNavigationAdapter } from '@mindtwo/app-component';
const bridge = await AppComponent.create({
name: 'my-app',
component: MyApp,
navigation: createEventNavigationAdapter(),
hooks: {
// component asked the host to navigate
navigate: ({ url, action }) => console.log('component wants to', action, '→', url),
},
});// in the host page
const bridge = window.MyApp;
// React to host-side navigation by syncing the component:
window.addEventListener('hostrouterchange', (e) => {
bridge.navigate(e.detail.url);
});useNavigation() returns the same shape for both adapters:
interface NavigationApi {
currentUrl: Readonly<Ref<ParsedUrl>>; // { href, pathname, search, hash, searchParams }
push: (url: string) => void;
replace: (url: string) => void;
}Route matching is intentionally out of scope — pair the adapter with vue-router if you need it. The adapter only owns "what is the current URL?" and "ask to change it."
@mindtwo/unplugin-app-component can emit a manifest-loader script that fetches a Vite-style manifest and injects the entry scripts/styles into the page at runtime. Enable it via the manifestLoader option on the bundler plugin.
For environments that need to defer or gate script loading (consent banners, Nuxt apps, CSP-strict pages), provide a scriptLoader module specifier. The runtime dynamically imports that module and delegates script injection to its default export.
// vite.config.ts
import AppComponentPlugin from '@mindtwo/unplugin-app-component/vite';
export default {
plugins: [
AppComponentPlugin({
manifestLoader: true,
scriptLoader: '@mindtwo/app-component-nuxt-scripts',
}),
],
};The shipped @mindtwo/app-component-nuxt-scripts adapter wraps useScript() from nuxt-scripts. Provide your own module to integrate with a different loader — it must default-export a function matching:
export type ScriptLoader = (script: { src: string; id?: string }) => Promise<void>;This monorepo provides four packages:
@mindtwo/app-component— the runtime helper described above.@mindtwo/app-component-composables— Vue composables for inside-component access to hooks, bridge, and root.@mindtwo/unplugin-app-component— a bundler plugin (Vite / Rollup / Rolldown) to register app components from source and optionally generate a manifest loader.@mindtwo/app-component-nuxt-scripts—nuxt-scriptsadapter for the manifest loader'sscriptLoaderoption.
This project is licensed under the MIT License.