SSR and hydration
How ilha renders HTML on the server and restores interactivity in the browser through hydration.
Server-side rendering (SSR) turns an island into HTML on the server. Hydration restores that HTML to a live, interactive island in the browser. This page is the mental model; each method page below carries the details.
Choose a render mode
You pick a server API by whether you need async work (derived values, streams) and whether you want hydration markup.
| API | Env | Output |
|---|---|---|
Island.toString(props) |
Server | Synchronous HTML |
await Island.toStringAsync(props) |
Server | Async HTML, by name |
await Island(props) |
Server | Async HTML shorthand |
await Island.hydratable(props, options) |
Server | HTML plus hydration data |
mount({ Island }) |
Client | Discovers and mounts hosts |
Use Island.toString(props) for plain markup that has no async work. Async SSR awaits derived values and streamed state, so prefer await Island.toStringAsync(props) — or its shorthand await Island(props) — whenever the island may resolve async data. You never call Island(props) unawaited; the caller always awaits it.
Why toStringAsync() exists
Island.toString() always renders synchronously — derived values that are still loading render in their loading state, and streamed keys fall back to their initial value. Island.toStringAsync() returns a Promise and awaits async derived values and the first streamed value before producing HTML. Prefer it by name when you want explicit async SSR:
import { const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 6 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha } from "ilha";
const const Island: Island<RootInput, RootState>Island = const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 6 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha
.IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.derived<"user", any>(key: "user", fn: DerivedFn<RootInput, RootState, any>): IlhaBuilder<RootInput, RootState, RootDerived & Record<"user", any>, RootActions>derived("user", async () => {
const const res: Responseres = await function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch)fetch("/api/user");
return const res: Responseres.Body.json(): Promise<any>[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)json();
})
.IlhaBuilder<RootInput, RootState, RootDerived & Record<"user", any>, RootActions>.render(fn: (ctx: RenderContext<RootInput, RootState, RootDerived & Record<"user", any>, RootActions>) => string | RawHtml): Island<RootInput, RootState>render(({ derived: IslandDerived<RootDerived & Record<"user", any>>derived }) => {
if (derived: IslandDerived<RootDerived & Record<"user", any>>derived.user: DerivedAccessor<any>user.loading: booleanloading) return <"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>Loading…</"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>;
return <"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>{derived: IslandDerived<RootDerived & Record<"user", any>>derived.user: () => any (+1 overload)user()?.name}</"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>;
});
// Async — waits for derived values
const const html: stringhtml = await const Island: Island<RootInput, RootState>Island.Island<RootInput, RootState>.toStringAsync(props?: Partial<RootInput> | undefined): Promise<string>Async SSR: renders the island and awaits async `.derived()` values before
returning the HTML string. Always returns a Promise — prefer this over
`await island(props)` when you need the async SSR form by name.toStringAsync();
Hydration markup
await Island.hydratable(props, options) emits HTML wrapped so the client can restore serialized props and snapshots when it calls mount({ Island }):
import { const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 6 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha } from "ilha";
const const Island: Island<RootInput, MergeState<RootState, "count", number>>Island = const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 6 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha
.IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.state<number, "count">(key: "count", init?: StateInit<RootInput, number> | undefined): IlhaBuilder<RootInput, MergeState<RootState, "count", number>, RootDerived, RootActions>state("count", 0)
.IlhaBuilder<RootInput, MergeState<RootState, "count", number>, RootDerived, RootActions>.render(fn: (ctx: RenderContext<RootInput, MergeState<RootState, "count", number>, RootDerived, RootActions>) => string | RawHtml): Island<RootInput, MergeState<RootState, "count", number>>render(({ state: IslandState<MergeState<RootState, "count", number>>state }) => <"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>{state: IslandState<MergeState<RootState, "count", number>>state.count: MarkedSignalAccessor
() => number (+1 overload)
count()}</"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>);
// On the server:
await const Island: Island<RootInput, MergeState<RootState, "count", number>>Island.Island<RootInput, MergeState<RootState, "count", number>>.hydratable(props: Partial<RootInput>, options: HydratableOptions): Promise<string>hydratable({}, { HydratableOptions.name: stringname: "my-island" });
On the client, mount({ Island }) auto-discovers [data-ilha="IslandName"] elements and hydrates them.
What runs where
| Concern | Runs on server | Runs on client |
|---|---|---|
| Render function | Yes | Yes, on re-render |
.derived() sync |
Yes | Yes |
.derived() async |
Yes (awaited in async SSR) | Yes |
.stream() |
Pulls first value only | Consumes continuously |
.action() |
No-op (idle) | Yes |
.on() / .effect() |
No | Yes |
.onMount() |
No — client-only | Yes |
.onError() / onUncaughtError() |
No | Yes |
.onMount() is client-only: SSR never invokes it, matching .on() and .effect(). Server-rendered markup must not depend on onMount side effects. If you previously seeded server-visible state from input inside onMount, migrate that work to a .state() initializer, module scope, or .derived().
Linking the method pages
.render()— the island methods and sync/async SSR forms.hydratable()— hydration options and snapshots.onMount()— client-only setup.derived()— async derived values.stream()— generator-fed statemount()— client mounting