Skip to content
brought to you byVoidZero
Private Beta:Void is currently in Private Beta. We do not recommend using it for mission-critical production workloads yet. Please back up your data regularly.

Islands

Islands mode is a partial hydration architecture inspired by Astro. Instead of hydrating the whole page on the client, only the interactive components, or "islands," ship JavaScript to the browser. The rest of the page stays as static server-rendered HTML.

This gives you the best of both worlds: fast initial page loads with minimal client-side JavaScript, plus rich interactivity exactly where you need it.

Prerequisites

Islands mode builds on top of Pages Routing. You need a working Pages setup (framework adapter installed, pages/ directory, Vite config) before using islands.

When to Use Islands

Islands mode is a good fit when:

  • Most of your page is static content: blog posts, marketing pages, or documentation
  • Only a few components need interactivity: a counter, a form, or a live widget
  • Performance is critical: you want near-zero JavaScript for static content

If your entire page is interactive (dashboards, apps with lots of client state), stick with regular Pages Routing.

Creating an Island Page

Name your page file with the .island suffix:

  • pages/
    • blog/
      • index.island.tsx← island page
      • index.server.ts
      • _Counter.tsxregular component, used as island
      • _PostForm.tsx

The .island suffix tells Void to:

  1. Server-render the full page as static HTML (no data-page attribute, no Void Router)
  2. Only hydrate the components you explicitly mark as islands
  3. Skip the Inertia protocol: navigation between island pages uses full page loads
  4. Auto-prerender: island pages with no loader and no dynamic params are automatically prerendered at deploy time. Opt out with export const prerender = false in the companion .server.ts file.

Marking Components as Islands

Use import attributes to mark which components should be interactive on the client:

tsx
// pages/blog/index.island.tsx
import Counter from './_Counter' with { island: 'load' };
import PostForm from './_PostForm' with { island: 'visible' };

export default function BlogIndex({ posts }) {
  return (
    <div>
      <h1>Blog</h1>
      <Counter />
      {posts.map((post) => (
        <a key={post.slug} href={`/blog/${post.slug}`}>
          {post.title}
        </a>
      ))}
      <PostForm />
    </div>
  );
}
vue
<!-- pages/blog/index.island.vue -->
<script setup>
import Counter from './_Counter.vue' with { island: 'load' };
import PostForm from './_PostForm.vue' with { island: 'visible' };

defineProps({ posts: Array });
</script>

<template>
  <div>
    <h1>Blog</h1>
    <Counter />
    <a v-for="post in posts" :key="post.slug" :href="`/blog/${post.slug}`">
      {{ post.title }}
    </a>
    <PostForm />
  </div>
</template>
svelte
<!-- pages/blog/index.island.svelte -->
<script>
import Counter from "./_Counter.svelte" with { island: "load" };
import PostForm from "./_PostForm.svelte" with { island: "visible" };

let { posts } = $props();
</script>

<div>
  <h1>Blog</h1>
  <Counter />
  {#each posts as post}
    <a href="/blog/{post.slug}">{post.title}</a>
  {/each}
  <PostForm />
</div>
tsx
// pages/blog/index.island.tsx
import Counter from './_Counter' with { island: 'load' };
import PostForm from './_PostForm' with { island: 'visible' };
import { For } from 'solid-js';

export default function BlogIndex(props) {
  return (
    <div>
      <h1>Blog</h1>
      <Counter />
      <For each={props.posts}>{(post) => <a href={`/blog/${post.slug}`}>{post.title}</a>}</For>
      <PostForm />
    </div>
  );
}

Components imported without the island attribute are rendered as static HTML only. No JavaScript is sent to the browser for them.

TypeScript configuration

Import attributes require "module": "ESNext" in your tsconfig.json:

json
{
  "compilerOptions": {
    "module": "ESNext"
  }
}

Hydration Strategies

The island attribute value controls when the component hydrates:

StrategyHydrates when...Use case
"load"Page loadsCritical interactive UI (forms, nav menus)
"visible"Element enters the viewportBelow-the-fold content
"idle"Browser is idleNon-critical enhancements
"media:(query)"CSS media query matchesResponsive components (e.g., mobile-only)
tsx
import CookieBanner from './_CookieBanner' with { island: 'idle' };
import MobileMenu from './_MobileMenu' with { island: 'media:(max-width: 768px)' };
import Comments from './_Comments' with { island: 'visible' };

Server Handlers

Island pages use the same loader and action pattern as regular pages. The .server.ts companion file works identically:

ts
// pages/blog/index.server.ts
import { defineHandler } from 'void';

export const loader = defineHandler((c) => {
  return c.json({ posts: getAllPosts() });
});

export const action = defineHandler(async (c) => {
  const body = await c.req.json();
  // validate, create post...
  return c.json({ success: true });
});

The key difference is what happens after a successful action. On a regular page, the Inertia protocol redirects and the Void Router fetches fresh props as JSON. On an island page, there is no Void Router, so successful actions cause a full page reload or a redirect through window.location.

Forms

Island pages cannot use useForm because it depends on the Void Router. Use useIslandForm instead. It has the same API, but it uses fetch() directly:

tsx
import { useIslandForm } from '@void/react';

export default function PostForm() {
  const form = useIslandForm({ title: '', body: '' });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        return form.post('/blog');
      }}
    >
      <label htmlFor="title">Title:</label>
      <input
        id="title"
        value={form.data.title}
        onChange={(e) => form.setData('title', e.target.value)}
      />
      {form.errors.title && <span>{form.errors.title}</span>}

      <label htmlFor="body">Body:</label>
      <textarea
        id="body"
        value={form.data.body}
        onChange={(e) => form.setData('body', e.target.value)}
      />
      {form.errors.body && <span>{form.errors.body}</span>}

      <button disabled={form.pending}>{form.pending ? 'Saving...' : 'Add Post'}</button>
    </form>
  );
}
vue
<script setup>
import { useIslandForm } from '@void/vue';

const form = useIslandForm({ title: '', body: '' });

function submit() {
  return form.post('/blog');
}
</script>

<template>
  <form @submit.prevent="submit">
    <label for="title">Title:</label>
    <input id="title" v-model="form.data.title" />
    <span v-if="form.errors.title">{{ form.errors.title }}</span>

    <label for="body">Body:</label>
    <textarea id="body" v-model="form.data.body" />
    <span v-if="form.errors.body">{{ form.errors.body }}</span>

    <button :disabled="form.pending">
      {{ form.pending ? 'Saving...' : 'Add Post' }}
    </button>
  </form>
</template>
svelte
<script>
import { useIslandForm } from "@void/svelte";

const form = useIslandForm({ title: "", body: "" });

function submit() {
  return form.post("/blog");
}
</script>

<form on:submit|preventDefault={submit}>
  <label for="title">Title:</label>
  <input id="title" bind:value={form.data.title} />
  {#if form.errors.title}<span>{form.errors.title}</span>{/if}

  <label for="body">Body:</label>
  <textarea id="body" bind:value={form.data.body} />
  {#if form.errors.body}<span>{form.errors.body}</span>{/if}

  <button disabled={form.pending}>
    {form.pending ? "Saving..." : "Add Post"}
  </button>
</form>
tsx
import { useIslandForm } from '@void/solid';
import { Show } from 'solid-js';

export default function PostForm() {
  const form = useIslandForm({ title: '', body: '' });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        return form.post('/blog');
      }}
    >
      <label for="title">Title:</label>
      <input
        id="title"
        value={form.data.title}
        onInput={(e) => form.setData('title', e.target.value)}
      />
      <Show when={form.errors.title}>
        <span>{form.errors.title}</span>
      </Show>

      <label for="body">Body:</label>
      <textarea
        id="body"
        value={form.data.body}
        onInput={(e) => form.setData('body', e.target.value)}
      />
      <Show when={form.errors.body}>
        <span>{form.errors.body}</span>
      </Show>

      <button disabled={form.pending}>{form.pending ? 'Saving...' : 'Add Post'}</button>
    </form>
  );
}

useIslandForm returns the same shape as useForm:

PropertyTypeDescription
dataTReactive form state
setData(field, value)FunctionUpdate a field
errorsRecord<string, string>Validation errors from 422 responses
errorVoidActionError | nullNon-validation call-site action error
pendingbooleanSubmission in progress
hasChangesbooleanForm has unsaved changes
wasSuccessfulbooleanLast submission succeeded
recentlySuccessfulbooleanSuccess within last 2 seconds
reset(...fields?)FunctionReset to defaults (all or specific fields)
clearErrors(...fields?)FunctionClear errors (all or specific fields)
clearError()FunctionClear the non-validation call-site error
post(url)FunctionSubmit via POST
put(url)FunctionSubmit via PUT
patch(url)FunctionSubmit via PATCH
delete(url)FunctionSubmit via DELETE

The submit helpers return Promise<void> so callers and framework event handlers can observe boundary-class failures. On success (200), the page reloads. On validation error (422), errors is populated from the response { errors: { field: "message" } }. On redirect, the browser follows it.

Island pages do not have a Void Router. Use regular <a> tags for navigation:

tsx
// ✅ Use regular links in island pages
<a href="/blog/my-post">Read more</a>

// ❌ Don't use <Link>; there is no Void Router to handle it
<Link href="/blog/my-post">Read more</Link>

When navigating from a regular page to an island page (e.g., via <Link>), the router detects that the target is an island page and falls back to a full-page navigation automatically.

Layouts

Island pages support layouts the same way as regular pages. The layout wraps the page during server rendering. Layout components in island pages are static only, so they are not hydrated on the client.

Mixing Island and Regular Pages

Island pages and regular pages can coexist in the same app:

  • pages/
    • index.tsxregular page (full hydration, Void Router)
    • about.tsxregular page
    • blog/
      • index.island.tsxisland page (partial hydration, static HTML)
      • [slug].island.tsxisland page

The Void Router handles navigation between regular pages. Navigating to or from an island page triggers a full-page load.