Islands Architecture

ubean supports partial hydration via islands architecture. Islands are interactive components hydrated on the client, while the rest of the page stays as static HTML. This ships less JavaScript and keeps content fast.

The v-client Directive

Use the v-client.* Vue directive to mark a component for client-side hydration. The modifier (.load, .idle, .visible, .media, .only) selects the hydration strategy:

<script setup lang="ts">
import { ref } from 'vue';
import Counter from '~/components/Counter.vue';

const count = ref(0);
</script>

<template>
  <Counter v-client.load :initial="0" />
</template>

The directive goes on the component element, not a plain <div>:

<template>
  <!-- Correct: directive on component -->
  <Counter v-client.load />

  <!-- Wrong: directive on plain element has no effect -->
  <div v-client.load>...</div>
</template>

Hydration Strategies

v-client.load

Hydrate immediately when the page loads (best for above-the-fold interactive elements):

<Counter v-client.load />

v-client.idle

Hydrate when the browser is idle via requestIdleCallback:

<HeavyChart v-client.idle />

v-client.visible

Hydrate when the element scrolls into view (via IntersectionObserver):

<Comments v-client.visible />

v-client.media

Hydrate when a CSS media query matches. The value is a Vue expression, so string literals must be quoted:

<MobileNav v-client.media="'(max-width: 768px)'" />

You can also pass a reactive variable:

<script setup lang="ts">
import { ref } from 'vue';
const query = ref('(max-width: 768px)');
</script>

<template>
  <MobileNav v-client.media="query" />
</template>

v-client.only

Render only on the client (no SSR output for this component):

<ClientOnlyWidget v-client.only />

v-client.only vs <ClientOnly>

ubean also ships a <ClientOnly> component (from ubean/client / @ubean/vue) for the non-island case:

<template>
  <ClientOnly fallback="loading…">
    <BrowserChart />
  </ClientOnly>
</template>
Use v-client.onlyUse <ClientOnly>
Component-level islands architecture (registry-driven hydration)Template fragments / plain HTML (the islands transform only matches capitalized component tags)
Needs a deferral strategy alongside (load/idle/visible/media)Same-tree rendering with full app context, #fallback slot / fallback prop
Content inside the island is replaced by a separate app on hydrationNot inside an island placeholder (its content is wiped when the island hydrates)

<ClientOnly> is hydration-safe: SSR and the client’s first paint render identical placeholder output, and the real content is patched in after mount.

Server / Client Components (.server.vue / .client.vue)

Two file-name conventions for components that belong to exactly one build graph. The Vite plugin handles the resolution — no import change and no directive needed.

  • Foo.client.vue — renders only on the client. SSR outputs the <!--client-only--> comment placeholder (the same one <ClientOnly> uses) and the real component replaces it after mount. For components that depend on browser APIs.
  • Foo.server.vue — renders only on the server. The client build replaces the import with a stub, so neither the component’s implementation nor its imports ever reach the client bundle. For content that needs server-only access (env vars, databases).
  • Foo.server.vue + Foo.client.vue together — import the base name (import Foo from './Foo.vue'); that file must not exist. SSR renders the server half and the client half takes over once mounted. Relative and aliased specifiers both work. If a real Foo.vue also exists it wins, and the two halves are ignored.

Constraints

.server.vue must be a <template>-based SFC. On the server the template is wrapped in <ubean-server-only v-once> so hydration can match it; a render-function SFC (or export { default } from …) has nothing to wrap and the build fails with that reason. The silent alternative was measured worse: server output and client stub disagreed on the root element, and hydration removed the server-rendered content.

.server.vue occupies a wrapper element, so its context matters. The client keeps a stub in the tree — it renders <ubean-server-only> where the server put the content — and hydration preserves the server-rendered children by matching that element. The wrapper is an ordinary flow element, so a server component may only be used where a flow element is allowed:

<!-- breaks: the wrapper is not valid inside <tr>, so the parser hoists it out of the table -->
<table>
  <tr>
    <ServerGreeting />
  </tr>
</table>

<!-- fine: the server component renders the container itself -->
<ServerTable />

The plugin rejects this at transform time — using a .server.vue component directly under one of those parents fails the build (dev overlay / vite build error) with the parent and the component named. That failure is deliberate: the symptom it prevents is silent in production. Measured before the check existed: on first paint the content shows up outside the container (moved before the table, leaving an empty <tr>), and after hydration Vue reports Hydration node mismatch / Hydration children mismatch and removes the content.

The check covers the table ancestors (table, thead, tbody, tfoot, tr, colgroup) and select / optgroup, where the parser either moves the wrapper out or drops it. It deliberately does not cover ul / ol / dl: there the wrapper stays put (invalid HTML, but the DOM is identical on both sides, so hydration is fine).

Workarounds: let the server component render the container (the whole <table> / <ul>), or place it inside an allowed child (<td>, <li>). .client.vue has no such constraint — a comment node is legal in every context.

Island Components

Create reusable island components:

<!-- src/components/Counter.client.vue -->
<script setup lang="ts">
import { ref } from 'vue';

defineProps<{
  initial?: number;
}>();

const count = ref(0);
</script>

<template>
  <div>
    <button @click="count++">+</button>
    <span>{{ count }}</span>
    <button @click="count--">-</button>
  </div>
</template>

Use in pages with a v-client.* directive:

<template>
  <Counter v-client.load :initial="10" />
</template>

Props

Props passed to islands must be JSON-serializable (strings, numbers, booleans, arrays, plain objects):

<template>
  <UserProfile v-client.visible :user-id="123" :theme="'dark'" />
</template>

Events

Islands can emit events to the parent:

<!-- Island component -->
<script setup lang="ts">
const emit = defineEmits<{
  update: [value: number];
}>();

const count = ref(0);

function handleUpdate() {
  emit('update', count.value);
}
</script>
<!-- Parent page -->
<template>
  <Counter v-client.load @update="handleUpdate" />
</template>

<script setup lang="ts">
function handleUpdate(value: number) {
  console.log('Updated:', value);
}
</script>

Automatic Hydration (Zero-Config)

ubean automatically hydrates islands after the app mounts — no manual hydrateIslands() call needed. The framework:

  1. Transforms v-client.* directives into <ubean-island v-once> custom elements at build time, with data attributes carrying component info and serialized props
  2. Auto-registers island components via virtual:ubean-islands-registry (generated by scanning <script setup> imports)
  3. After app.mount(), uses double requestAnimationFrame to wait for Vue’s render cycle to complete, then hydrates all islands
  4. On SPA navigation (router.afterEach), automatically hydrates islands on the new page
  5. A bootstrap IIFE sets data-hydrating based on the directive strategy, and hydrateIslands() uses MutationObserver as a fallback

You don’t need any code in app.ts for islands to work:

// app.ts — zero code needed for islands
import { defineApp } from 'ubean/client';

export default defineApp({
  // islands are auto-hydrated by the framework
});

Just import the component in <script setup> and use a v-client.* directive — ubean handles the rest:

<script setup lang="ts">
import Counter from '~/components/Counter.vue';
</script>

<template>
  <Counter v-client.load />
</template>

How It Works

  1. Build/Dev scan: ubeanIslandsPlugin scans .vue files for v-client.* directives
  2. Template transform: Replaces island component tags with <ubean-island v-once> custom elements (v-once prevents Vue from overwriting hydrated content)
  3. Import resolution: Parses <script setup> imports to map component names to file paths
  4. Virtual module: Generates virtual:ubean-islands-registry exporting all collected components
  5. Runtime bridge: hydrateIslands in ubean/client auto-imports the registry and merges with any manual components
  6. Auto-hydration: The client entry automatically calls hydrateIslands() after mount (double rAF) and after each SPA navigation
  7. HMR: Dev mode auto-updates the registry when new v-client.* directives are added (full-reload)
  8. Tree-shaking: Only components actually used with v-client.* directives are included in the client bundle

Manual Registration (Escape Hatch)

For edge cases where auto-registration doesn’t work (globally registered components, defineAsyncComponent, dynamic imports), you can pass components manually by calling hydrateIslands in onClientReady. Manual registration takes precedence over auto-registration:

// app.ts — hybrid mode (auto + manual)
import { defineApp, hydrateIslands } from 'ubean/client';
import DynamicIsland from '~/components/DynamicIsland.vue';

export default defineApp({
  onClientReady: app => {
    // Framework auto-hydrates registered islands; add manual components here
    hydrateIslands({
      appContext: app,
      components: {
        // This component wasn't statically imported, so auto-registry can't find it
        DynamicIsland
      }
    });
  }
});

Note: When you manually call hydrateIslands() in onClientReady, it runs in addition to the framework’s automatic call. Already-hydrated islands are skipped via the data-hydrated attribute, so there is no double-hydration. Manual components are merged with auto-registered components.

Diagnostics

When an island component is not found in the registry, ubean outputs a helpful warning to the console:

[ubean:islands] Island component "MyComp" not found in registry.
Possible causes:
  1. Component is globally registered or dynamically imported — pass it via hydrateIslands({ components: { MyComp: YourComp } })
  2. Component name mismatch between template tag and import
  3. Component is auto-imported by unplugin-vue-components (no static import → not in auto-registry)
Registered components: Counter, Chart, Comments

If a v-client.* directive is used on a component without a static import, a build-time warning is emitted:

[ubean:islands] Component "GloballyRegistered" used with v-client.xxx directive in /src/pages/test.vue
has no corresponding static import in <script setup>. It will not be auto-registered.
Add it manually via hydrateIslands({ components: { GloballyRegistered: YourComp } }).

Programmatic Islands: defineIsland()

For programmatic use (e.g. dynamic component resolution where a template directive is impractical), use the defineIsland(Component, strategy, options?) runtime wrapper. It applies the same hydration strategy as the v-client.* directive.

import { defineIsland } from 'ubean';
import Counter from '~/components/Counter.vue';

// strategy: 'load' | 'idle' | 'visible' | 'media' | 'only'
const CounterIsland = defineIsland(Counter, 'load');

// 'media' strategy requires a mediaQuery option
const MobileNavIsland = defineIsland(MobileNav, 'media', {
  mediaQuery: '(max-width: 768px)'
});

// Pre-bind props
const ProfileIsland = defineIsland(UserProfile, 'visible', {
  props: { theme: 'dark' }
});
ParameterTypeDescription
ComponentComponentThe Vue component to wrap as an island
strategy'load' | 'idle' | 'visible' | 'media' | 'only'Hydration strategy (matches v-client.* modifiers)
options{ mediaQuery?: string, props?: Record<string, unknown> }mediaQuery required for 'media' strategy; props pre-binds props

Server Islands: defineServerIsland()

Use the defineServerIsland(Component, options?) runtime wrapper for server islands. It wraps an async component in <Suspense> with a fallback, sets inheritAttrs: false, and forwards attrs + slots to the inner Component.

import { defineServerIsland } from 'ubean';
import AsyncChart from '~/components/AsyncChart.vue';

// Default fallback (<ubean-defer-fallback> placeholder)
const Chart = defineServerIsland(AsyncChart);

// Custom fallback (string | Component)
const ChartWithFallback = defineServerIsland(AsyncChart, {
  fallback: '<div class="skeleton">Loading…</div>'
});

ServerIslandOptions = { fallback?: Component | string } — when omitted, a <ubean-defer-fallback> placeholder is used. During prerender only the fallback (static shell) is rendered; during streaming SSR the resolved async component is streamed out via the Suspense boundary.

Performance Tips

  1. Limit islands: Only hydrate what’s truly interactive
  2. Use v-client.visible: Defer below-the-fold content
  3. Use v-client.idle: Defer non-critical islands
  4. Keep islands small: Break large components into smaller islands
  5. Reserve space: Avoid layout shifts by reserving space for hydrated content
  6. Static pages with no islands: Pages without any v-client.* directive and no client-side interactivity avoid island hydration overhead entirely

Best Practices

  1. Identify interactive parts: Only mark truly interactive components as islands
  2. Choose the right strategy: Match the directive modifier to user interaction patterns
  3. Test hydration: Verify islands hydrate correctly without console errors
  4. Monitor performance: Use DevTools to check hydration timing
  5. Avoid SSR-only code in islands: Islands run on both server and client (except v-client.only)