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/voidpnpm add @ilha/voidyarn add @ilha/voidbun add @ilha/voidnub add @ilha/voidaube add @ilha/voidilha, 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.
Navigate with Link
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
.mdpages are untested with@ilha/void. - Island pages load Void’s router chunk, because
initIslands()ships invoid/pages-client.