Skip to content
Ilha
Esc
↑↓navigate↵open⌘Jpreview
On this page

Void

Render Void pages, layouts, forms, and islands with Ilha components.

@ilha/void renders Void pages with Ilha. Void handles file routing, loaders, actions, and deployment. @ilha/void renders each page with renderToString() and hydrates it with mount().

It is a separate package from ilha. Add it when you build a server-rendered app.

Install

npm install @ilha/void
pnpm add @ilha/void
yarn add @ilha/void
bun add @ilha/void
nub add @ilha/void
aube add @ilha/void

ilha, vite, and void are peer dependencies. Void needs Node.js 24.21 or newer.

Quick start

Add voidIlha() after voidPlugin() in your Vite config.

// vite.config.ts
import { voidIlha } from "@ilha/void/plugin";
import { defineConfig } from "vite";
import { voidPlugin } from "void";

export default defineConfig({
  plugins: [voidPlugin(), voidIlha()],
});

Set jsxImportSource to ilha in tsconfig.json. Then create a page in pages/. Its default export is an Ilha component, and its props are the result of the page’s loader.

// pages/index.server.ts
import { defineHandler } from "void";
import type { InferProps } from "void";

export type Props = InferProps<typeof loader>;

export const loader = defineHandler(() => ({ todos: ["Ship it"] }));
// pages/index.tsx
import type { Props } from "./index.server.ts";

export default function Todos({ todos }: Props) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo}>{todo}</li>
      ))}
    </ul>
  );
}

Run vite to start the dev server. Scaffold a full app with npx giget@latest gh:ilhajs/ilha/templates/void.

How rendering works

Void calls your page on the server, and @ilha/void renders it with its layouts through await renderToString(). In the browser, mount(host, Page, { hydrate: true }) restores atom state from the snapshot in the HTML.

Layouts live in pages/layout.tsx and wrap every page below them. A layout keeps its atom state and DOM across navigations. A page starts fresh on each navigation, because @ilha/void keys it by route.

Link renders an <a> that the Void Router handles without a full page load.

import { Link } from "@ilha/void";

export default function Nav() {
  return (
    <nav>
      <Link href="/" prefetch>
        Home
      </Link>
      <Link href="/users/7" prefetch="visible" preserveScroll>
        Ada
      </Link>
    </nav>
  );
}
Prop Meaning
prefetch true (hover), or "hover", "click", "mount", "visible", or a list
cacheFor How long prefetched data stays fresh, such as "30s"
preserveScroll, preserveState Keep scroll position or page state across the visit
replace Replace the history entry
reloadDocument Skip the router and load the document
method, data Render a <button> that submits data with a non-GET method
viewTransition Wrap this visit in document.startViewTransition()

Other attributes pass through to the element. Modified clicks, download links, and cross-origin links fall back to the browser.

Read route state

These hooks read from the current page and rerun your component when the value changes. Call them at the top level, like atom().

import { useNavigation, useParams, useRouter, useShared } from "@ilha/void";

export default function UserPage() {
  const { id } = useParams<{ id: string }>();
  const { appName } = useShared<{ appName: string }>();
  const navigation = useNavigation();
  const router = useRouter();
  return (
    <section>
      <h1>
        {appName}: user {id}
      </h1>
      {navigation.state === "loading" ? <p>Loading…</p> : null}
      <button type="button" onclick={() => router.refresh()}>
        Refresh
      </button>
    </section>
  );
}
Hook Returns
useParams() Route params
useShared() Values your middleware shares with every page
useNavigation() { state, location, method }, idle during SSR and on island pages
useRouter() The Void Router: visit(), refresh(), prefetch(), flush()

Submit forms

useForm(url, defaults) binds field state to a page action. It submits through the Void Router, so a successful submit refreshes the page props without a reload.

import { Form, useForm } from "@ilha/void";

export default function NewTodo() {
  const form = useForm("/", { text: "" });
  return (
    <Form form={form}>
      <input
        value={form.data.text}
        oninput={({ currentTarget }) =>
          form.setData("text", currentTarget.value)
        }
      />
      {form.errors.text ? <p>{form.errors.text}</p> : null}
      <button type="submit" disabled={form.pending}>
        Add
      </button>
    </Form>
  );
}

form exposes data, setData(), errors, error, pending, hasChanges, wasSuccessful, recentlySuccessful, reset(), clearErrors(), and clearError(). Submit with post(), put(), patch(), or delete(). A 422 response fills errors from { errors: { field: "message" } }.

Form calls form.post() on submit, shows the form error in a role="alert" element, and blocks resubmits while a rate-limit retry window is open. Pass method="put" to change the verb.

When you generate Void’s route types, useForm("/path", …) takes its field types from the page’s action.

Add islands

Name a page *.island.tsx to render it as static HTML. Import a component with with { island } to hydrate only that component.

// pages/about.island.tsx
import Counter from "./_counter" with { island: "visible" };

export default function About() {
  return (
    <section>
      <h1>About</h1>
      <Counter start={1} />
    </section>
  );
}

The strategy is "load", "visible", "idle", or "media:(query)". Island props must be JSON, and children do not cross to the browser. Files that start with _ are components, not routes.

Island pages have no Void Router, so use plain <a> tags and useIslandForm(). It takes the action URL in post(url) and reloads the page after a successful submit.

import { useIslandForm } from "@ilha/void";

export default function Subscribe() {
  const form = useIslandForm({ email: "" });
  return (
    <form
      onsubmit={(event) => {
        event.preventDefault();
        return form.post("/subscribe");
      }}
    >
      <input
        value={form.data.email}
        oninput={({ currentTarget }) =>
          form.setData("email", currentTarget.value)
        }
      />
      <button type="submit">Subscribe</button>
    </form>
  );
}

Current limits

  • Void’s Markdown pages detect a fixed list of adapters, so .md pages are untested with @ilha/void.
  • Island pages load Void’s router chunk, because initIslands() ships in void/pages-client.

Was this page helpful?