Skip to content
Built with

Islands ​

Build & Deploy

Islands add interactive components to an otherwise static page. The server renders HTML, and only components marked as islands send JavaScript to the browser. The idea comes from Astro.

On this pageMark a component as an island · Choose when an island loads · Keep pages fast with less JavaScript

For example, a blog post can keep its article content static while loading JavaScript for a comment form or audio player.

Prerequisites

Islands mode builds on Pages Routing. First set up Pages: a framework adapter, a pages/ directory and a Vite config.

When to use islands ​

Use islands for blogs, marketing pages, or documentation with a few interactive components. For dashboards and apps with shared client state, use regular Pages Routing.

Creating an island page ​

This step creates an island page. Give the page file the .island suffix.

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

Island pages render as HTML. Only components marked as islands hydrate. Navigation uses full page loads.

Pages without a loader or dynamic params are prerendered automatically. To opt out, add export const prerender = false in the companion .server.ts file.

Marking components as islands ​

This step marks interactive components with import attributes.

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
import { For } from 'solid-js';
// pages/blog/index.island.tsx
import Counter from './_Counter' with { island: 'load' };
import PostForm from './_PostForm' with { island: 'visible' };

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 render as static HTML. They send no JavaScript.

TypeScript configuration

Import attributes need "module": "ESNext" in tsconfig.json:

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

Hydration strategies ​

The island attribute value sets 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 Comments from './_Comments' with { island: 'visible' };
import CookieBanner from './_CookieBanner' with { island: 'idle' };
import MobileMenu from './_MobileMenu' with { island: 'media:(max-width: 768px)' };

Server handlers ​

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

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 });
});

After a successful action, an island page reloads or redirects through window.location. Regular pages fetch fresh props through the Void Router instead.

Forms ​

Island pages cannot use useForm, which needs the Void Router. This step uses useIslandForm instead. Its submit helpers take the action URL and call 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 provides the same form state as useForm. Pass the action URL to post, put, patch, or delete to submit.

Successful submissions reload the page. Redirects go to their destination. A 422 response populates errors from { errors: { field: "message" } }. Submit helpers return Promise<void>.

Island pages have no Void Router. Use regular <a> tags to navigate.

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>

Navigating from a regular page to an island page, for example with <Link>, triggers a full page load automatically.

Layouts ​

Island pages support layouts like regular pages. The layout wraps the page during server rendering. In island pages, layouts are static only and never hydrate.

Mixing island and regular pages ​

Island and regular pages can live 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.

Next steps ​

implemented.stream AI