@uselab/vue-islands
v1.0.3
Published
Progressively mount Vue components ("islands") into server-rendered semantic HTML using data attributes.
Readme
vue-islands
A lightweight solution for adding SEO-friendly Vue interactivity to server-generated HTML.
vue-islands lets you use regular HTML as the foundation of your pages and progressively hydrate individual parts with Vue.
Rather than making your entire page a Vue application, you can add interactive Vue components exactly where you need them.
This makes it a great fit for applications where SEO is important, but a full SSR setup would be unnecessary or isn’t an option. Keep your existing backend, server-side templates, or static HTML, and enhance specific parts of the page with interactive Vue components.
<div data-component="my-widget">
<h2 data-props="title=text-content">Hello world</h2>
<p data-props="description=text-content">
This content is SEO-friendly.
</p>
</div>Why Vue Islands?
- SEO-friendly: Because the page is server-rendered, search engines can index it without needing to execute JavaScript. Only the interactive parts are hydrated with Vue.
- Progressive hydration: hydrate only the parts of the page that need to be interactive.
- Use any Vue component: use Vue Islands with your existing Vue components without requiring a specific component structure.
- Works with your existing stack: use your current backend, templating system, or static HTML.
- 100% W3C-valid HTML: islands are defined using valid HTML that remains fully semantic.
- Lazy-loadable: components can be loaded only when they’re needed.
Progressively mount Vue components ("islands") into server-rendered semantic HTML, using
data-component attributes to declare which elements should become Vue components.
Components can be used as Vue islands as well as in the normal Vue way, so you can mix and match server-rendered HTML with client-side.
Install
npm install @uselab/vue-islands vuevue (^3.0.0) is a peer dependency, so use whichever Vue 3 version your project already depends
on. zod (^4.0.0) is also an optional peer dependency, only required if you use
validateRawProps:
npm install zodUsage
Mark up your server-rendered HTML with data-component (and optionally data-props)
attributes:
<div data-component="my-widget" data-title="Hello" data-count-json="3"></div>Then, in your TypeScript entry point, register the matching Vue components and call
initiateVueIslands once the DOM is ready:
import {
initiateVueIslands,
createVNodeFunction,
type GetVNode,
} from '@uselab/vue-islands';
import MyWidget from './MyWidget.vue';
const components: Record<string, GetVNode> = {
'my-widget': createVNodeFunction(MyWidget),
};
initiateVueIslands(components, {
// Optional: enable dev-only console logging of which islands get mounted.
isDevMode: import.meta.env.DEV,
});Component names can be written in camelCase in the components map (as above); data-component
attributes in the HTML are matched case-insensitively against the kebab-case form of these keys,
so menu, heavyChart, and imageCarousel above match data-component="menu",
data-component="heavy-chart", and data-component="image-carousel" respectively.
Each matching element is replaced (or, with data-keep-semantic-html, appended into) with a
mounted Vue app rendering the corresponding component, using attributes/data-props as props and
child elements as slots.
Declaring components and props in HTML
data-component="my-widget"— marks an element for mounting; the value is matched case-insensitively against thekebab-caseform of the key in yourcomponentsmap.data-component-tag-name="div"— overrides the tag name of the mounted root element (e.g.<ol data-component="my-widget" data-component-tag-name="div">mounts into a<div>instead of an<ol>). When omitted, the tag name of the original element is used.data-keep-semantic-html— when truthy ("","true", or"1"), the original server-rendered element is kept in the DOM and the mounted component is appended into it, instead of replacing it. Useful when the original element must stay in place for SEO, accessibility, or CSS reasons (e.g. an<a>that should keep behaving like a link until the island takes over).
Props can be defined in several ways:
- As plain attributes:
<div data-component="my-widget" title="Boo"> - As
data-{name}attributes:<div data-component="my-widget" data-title="Boo"> - As attributes ending in
-jsonto pass parsed JSON data:<div data-component="my-widget" data-options-json='{"key":"value"}' data-show-json="true" data-counter-json="12"> - In
data-props, as a query string mappingpropName=attributeName:<a data-component="my-widget" href="/boo" data-props="link-url=href">sets thelink-urlprop to the element'shrefvalue. - In
data-props, usingtext-contentto read the element's text:<div data-component="my-widget" data-props="title=text-content">Boo</div> - In
data-props, JSON parsing applies when either side of the mapping ends in-json:<div data-component="my-widget" data-items-json="[1,2,3]" data-props="items=data-items-json">and<div data-component="my-widget" data-props="items-json=text-content">[1,2,3]</div>both parse the value as JSON. - On any descendant element that has
data-propsbut notdata-component— useful for composing a prop object from several child elements without introducing extra slot markup.
Prop names in data-props support dot and bracket notation to build nested objects and arrays:
<div data-component="my-widget">
<h2 data-props="title=text-content">Boo</h2>
<img data-props="image.src=src&image.alt=alt" src="/image.jpg" alt="An image" />
<div data-props="cities[0]=text-content">Amsterdam</div>
<div data-props="cities[1]=text-content">Rotterdam</div>
</div>Descendant elements that don't have data-props (and aren't themselves data-component
islands) are passed through as slots instead, grouped by their slot or data-slot attribute:
<div data-component="my-widget">
<div slot="header">Boo</div>
<div slot="footer">Baa</div>
</div>A descendant element with its own data-component attribute is rendered as a normal Vue component
within the parent island, so slot content can include other registered components:
<div data-component="my-widget">
<div slot="header">
<span data-component="user-badge" data-name="Boo"></span>
</div>
</div>These nested components are not separate vue-islands — only the root [data-component]
elements queried by initiateVueIslands are individually mounted as their own App instance and
replace (or append into) their host element. A data-component found while walking a parent
island's slot content is turned into a plain Vue vnode instead, rendered as part of that parent
app. As a result, data-keep-semantic-html (and the replace-vs-append behavior it controls) only
has an effect on root elements; setting it on a nested component has no effect.
Configuring each mounted app
Every island is its own, independent Vue App instance — each data-component element gets its
own createApp(...)/mount(...) call under the hood. Use the configureApp option to run setup
logic against every one of these instances before it's mounted, e.g. to register an i18n plugin so
translations are available inside every island, or a shared Pinia instance so islands can read from
and write to the same store:
import { createPinia } from 'pinia';
import { createI18n } from 'vue-i18n';
import { initiateVueIslands } from '@uselab/vue-islands';
const pinia = createPinia();
const i18n = createI18n({ locale: 'en', messages });
initiateVueIslands(components, {
configureApp: async (app) => {
app.use(pinia);
app.use(i18n);
},
});Because configureApp runs for every island, a shared Pinia instance or just a simple vue ref object lets independently mounted
islands stay in sync with each other (and with the rest of the page), and a shared i18n instance
means every island can use the same translations and locale without reconfiguring it per component.
Lazy loading components
Because a GetVNode function is only invoked for elements that actually have a matching
data-component attribute on the page, you can wrap the component import in Vue's
defineAsyncComponent. This splits your code to code-split it into its own chunk that's only fetched when the island is
actually mounted:
import {
initiateVueIslands,
createVNodeFunction,
type GetVNode,
} from '@uselab/vue-islands';
import { defineAsyncComponent } from 'vue';
import MyWidget from './MyWidget.vue';
const components: Record<string, GetVNode> = {
// Loaded eagerly, e.g. because it's needed on (almost) every page.
'my-widget': createVNodeFunction(MyWidget),
// Loaded lazily: the chunk for `HeavyChart.vue` is only downloaded when a
// `data-component="heavy-chart"` element is found and mounted.
'heavy-chart': createVNodeFunction(
defineAsyncComponent(() => import('./HeavyChart.vue'))
),
};
initiateVueIslands(components);This keeps the initial bundle small: pages without a heavy-chart island never download its code,
while defineAsyncComponent still shows Vue's built-in loading/error states (via its
loadingComponent/errorComponent options) while the chunk loads.
Organizing many components
Projects with dozens of islands tend to converge on a small helper that wraps each component in a
GetVNode function, so the components map stays a flat, readable list of imports.
createVNodeFunction is exported for this purpose: it passes both the raw and the spread props to
the component, so components can use validateRawProps to log helpful errors (including the
original, unparsed props) when Zod validation fails:
import { defineAsyncComponent } from 'vue';
import {
initiateVueIslands,
createVNodeFunction,
type GetVNode,
} from '@uselab/vue-islands';
import Menu from './features/menu.vue';
import Header from './features/header.vue';
import Footer from './features/footer.vue';
const components: Record<string, GetVNode> = {
// Eagerly loaded components, e.g. because they're needed on (almost) every page.
menu: createVNodeFunction(Menu),
header: createVNodeFunction(Header),
footer: createVNodeFunction(Footer),
// Lazily loaded components: only fetched when a matching `data-component` element is found.
heavyChart: createVNodeFunction(
defineAsyncComponent(() => import('./features/heavy-chart.vue'))
),
imageCarousel: createVNodeFunction(
defineAsyncComponent(() => import('./features/image-carousel.vue'))
),
// ...more components
};
initiateVueIslands(components);Validating props with Zod
Because props parsed from HTML attributes arrive untyped, pairing validateRawProps with a Zod
schema catches malformed markup early and logs a clear console error — including the original,
unparsed rawProps — instead of failing silently or crashing deep inside the component:
<script setup lang="ts">
import * as z from 'zod';
import { type WithRawProps, validateRawProps } from '@uselab/vue-islands';
type Props = { items: { link?: string; title: string }[] } & WithRawProps;
const props = defineProps<Props>();
const validator = z.object({
items: z.array(
z.object({
link: z.string().optional(),
title: z.string(),
})
),
});
validateRawProps<Props>(validator.safeParse(props.rawProps || props), props);
</script>API
initiateVueIslands(components, options?)— scans the document (oroptions.doc) for[data-component]elements and mounts the matching Vue components (see "Declaring components and props in HTML" above for the supporteddata-*attributes).options.isDevMode?: boolean— enables verbose console logging for debugging (defaultfalse).options.configureApp?: (app: App) => Promise<void>— called for each created VueAppinstance before mounting, useful for registering plugins/directives (see "Configuring each mounted app" above).options.doc?: Document— alternate document to scan (default: globaldocument).
createVNodeFunction(component)— helper that turns a VueComponentinto aGetVNodefunction, passing it both the raw and the spread props (asWithRawProps) plus slots. Useful when registering many components (see "Organizing many components" above).validateRawProps(result, props)— helper for logging Zod prop-validation errors together with the raw (unparsed) props that were passed in. Requireszod(^4.0.0) to be installed.
Development
npm install
npm run build
npm run quality # runs typecheck, lint, and testsnpm install also sets up a pre-push git hook (via simple-git-hooks) that runs
npm run quality (lint, typecheck, and tests) before every git push, aborting the push if any of
them fail.
Publishing a new version
npm version patch(orminor/major) — runsnpm run qualityfirst (aborting on failure), then bumpspackage.json, commits, tags the commitvX.Y.Z, and pushes the commit and tag to GitHub.- Pushing the
vX.Y.Ztag triggers the CI workflow, which re-runs lint, typecheck, and the test suite on GitHub. Wait for it to pass. npm publish— buildsdist/(via theprepublishOnlyscript) and publishes the package to npm.
