npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 vue

vue (^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 zod

Usage

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 the kebab-case form of the key in your components map.
  • 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 -json to 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 mapping propName=attributeName: <a data-component="my-widget" href="/boo" data-props="link-url=href"> sets the link-url prop to the element's href value.
  • In data-props, using text-content to 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-props but not data-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&amp;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 (or options.doc) for [data-component] elements and mounts the matching Vue components (see "Declaring components and props in HTML" above for the supported data-* attributes).
    • options.isDevMode?: boolean — enables verbose console logging for debugging (default false).
    • options.configureApp?: (app: App) => Promise<void> — called for each created Vue App instance before mounting, useful for registering plugins/directives (see "Configuring each mounted app" above).
    • options.doc?: Document — alternate document to scan (default: global document).
  • createVNodeFunction(component) — helper that turns a Vue Component into a GetVNode function, passing it both the raw and the spread props (as WithRawProps) 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. Requires zod (^4.0.0) to be installed.

Development

npm install
npm run build
npm run quality # runs typecheck, lint, and tests

npm 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

  1. npm version patch (or minor / major) — runs npm run quality first (aborting on failure), then bumps package.json, commits, tags the commit vX.Y.Z, and pushes the commit and tag to GitHub.
  2. Pushing the vX.Y.Z tag triggers the CI workflow, which re-runs lint, typecheck, and the test suite on GitHub. Wait for it to pass.
  3. npm publish — builds dist/ (via the prepublishOnly script) and publishes the package to npm.