Skip to content

.hydratable()

Render islands wrapped in hydration containers with serialized props and optional state snapshots.

Updated View as Markdown

Renders the island wrapped in a hydration container. The output includes everything ilha.mount() needs to activate the island on the client — the rendered HTML, serialized props, and optionally a state snapshot — all embedded as data attributes on a wrapper element.

Use this method in your SSR handler whenever you want the island to become interactive in the browser without a full client-side re-render.

Basic usage

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;
    ... 5 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
from "ilha";
const const MyIsland: Island<RootInput, MergeState<RootState, "count", number>>MyIsland =
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;
    ... 5 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>);
const const html: stringhtml = await const MyIsland: Island<RootInput, MergeState<RootState, "count", number>>MyIsland .Island<RootInput, MergeState<RootState, "count", number>>.hydratable(props: Partial<RootInput>, options: HydratableOptions): Promise<string>hydratable({ count: numbercount: 42 }, { HydratableOptions.name: stringname: "MyIsland" }); // → '<div data-ilha="MyIsland" data-ilha-props="{"count":42}"> // <p>42</p> // </div>'

Options

interface HydratableOptions {
  name: string; // required
  as?: string; // default: "div"
  snapshot?: boolean | { state?: boolean; derived?: boolean }; // default: false
  skipOnMount?: boolean; // default: false
}
Option Type Default Description
name string Registry key used by mount() to find the matching island on the client
as string "div" Tag name for the wrapper element
snapshot boolean | object false Embed state and/or derived values in data-ilha-state
skipOnMount boolean false Skip all .onMount() callbacks when hydrating from snapshot

The name option

The name must match the key used when registering the island in your client-side mount() or hydrate() call:

// server
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;
    ... 5 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
from "ilha";
import { const mount: (registry: IslandRegistry, options?: MountOptions) => MountResultmount } from "ilha"; const const Counter: Island<RootInput, MergeState<RootState, "count", number>>Counter =
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;
    ... 5 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>);
const const html: stringhtml = await const Counter: Island<RootInput, MergeState<RootState, "count", number>>Counter.Island<RootInput, MergeState<RootState, "count", number>>.hydratable(props: Partial<RootInput>, options: HydratableOptions): Promise<string>hydratable({}, { HydratableOptions.name: stringname: "Counter" }); // client function mount(registry: IslandRegistry, options?: MountOptions): MountResultmount({ type Counter: Island<RootInput, MergeState<RootState, "count", number>>Counter }); // ← "Counter" matches the name above

If the name has no match in the registry, mount() skips the element silently.

The snapshot option

Snapshots embed current signal values into data-ilha-state so the client can restore them on mount without re-computing or re-fetching.

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;
    ... 5 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
from "ilha";
const const Counter: Island<RootInput, MergeState<RootState, "count", number>>Counter =
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;
    ... 5 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>);
// Snapshot state only await const Counter: Island<RootInput, MergeState<RootState, "count", number>>Counter.Island<RootInput, MergeState<RootState, "count", number>>.hydratable(props: Partial<RootInput>, options: HydratableOptions): Promise<string>hydratable( { count: numbercount: 5 }, { HydratableOptions.name: stringname: "Counter",
HydratableOptions.snapshot?: boolean | {
    state?: boolean;
    derived?: boolean;
} | undefined
snapshot
: true },
); // → data-ilha-state='{"count":5}' // Fine-grained control await const Counter: Island<RootInput, MergeState<RootState, "count", number>>Counter.Island<RootInput, MergeState<RootState, "count", number>>.hydratable(props: Partial<RootInput>, options: HydratableOptions): Promise<string>hydratable( { count: numbercount: 5 }, { HydratableOptions.name: stringname: "Counter",
HydratableOptions.snapshot?: boolean | {
    state?: boolean;
    derived?: boolean;
} | undefined
snapshot
: { state?: boolean | undefinedstate: true, derived?: boolean | undefinedderived: false },
}, );
snapshot value State snapshotted Derived snapshotted
false No No
true Yes Yes
{ state: true, derived: false } Yes No
{ state: false, derived: true } No Yes

When no snapshot is set, the island mounts fresh on the client — state initializers run again and .onMount() always fires.

The skipOnMount option

When restoring from a snapshot, you often do not want .onMount() to run — the DOM is already correct and setup work would be redundant. Set skipOnMount: true to suppress all .onMount() callbacks during hydration:

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;
    ... 5 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;
    ... 5 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
.IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.onMount(fn: (ctx: OnMountContext<RootInput, RootState, RootDerived, RootActions>) => (() => void) | void): IlhaBuilder<RootInput, RootState, RootDerived, RootActions>onMount(() => { var console: Consoleconsole.Console.log(...data: any[]): void
The **`console.log()`** static method outputs a message to the console. [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
log
("this is skipped on hydration");
}) .IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.render(fn: (ctx: RenderContext<RootInput, RootState, RootDerived, RootActions>) => string | RawHtml): Island<RootInput, RootState>render(() => <"div": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLDivElement>>div>hello</"div": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLDivElement>>div>); await const Island: Island<RootInput, RootState>Island.Island<RootInput, RootState>.hydratable(props: Partial<RootInput>, options: HydratableOptions): Promise<string>hydratable( {}, { HydratableOptions.name: stringname: "my-island",
HydratableOptions.snapshot?: boolean | {
    state?: boolean;
    derived?: boolean;
} | undefined
snapshot
: true,
HydratableOptions.skipOnMount?: boolean | undefinedskipOnMount: true, }, );

Note that skipOnMount only suppresses .onMount().effect() callbacks always run on mount regardless.

The as option

The wrapper element tag defaults to "div". Change it when the surrounding HTML requires a specific element — for example inside a <ul> where a <div> would be invalid:

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;
    ... 5 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
from "ilha";
const const Item: Island<RootInput, RootState>Item = ilha<RootInput>(fn: (ctx: RenderContext<RootInput, RootState, RootDerived, RootActions>) => string | RawHtml): Island<RootInput, RootState>ilha(() => <
"li": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLLIElement> & {
    value?: number;
}>
li
>item</
"li": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLLIElement> & {
    value?: number;
}>
li
>);
await const Item: Island<RootInput, RootState>Item.Island<RootInput, RootState>.hydratable(props: Partial<RootInput>, options: HydratableOptions): Promise<string>hydratable({}, { HydratableOptions.name: stringname: "item", HydratableOptions.as?: string | undefinedas: "li" }); // → '<li data-ilha="item">…</li>'

SSR output structure

The full rendered output looks like this:

<div
  data-ilha="MyIsland"
  data-ilha-props='{"count":42}'
  data-ilha-state='{"count":42}'
>
  <p>42</p>
</div>
  • data-ilha — the registry key, used by mount() for discovery.
  • data-ilha-props — serialized input props, read automatically on mount().
  • data-ilha-state — serialized signal snapshot, only present when snapshot is set.

With scoped styles

If the island uses .css(), the <style> tag is included inside the wrapper regardless of the snapshot option:

<div data-ilha="Card">
  <style data-ilha-css>
    @scope (:scope) to ([data-ilha]) {
      .title {
        font-weight: 700;
      }
    }
  </style>
  <div>
    <p class="title">Hello</p>
  </div>
</div>

With @ilha/router

When using file-system routing, .hydratable() is called internally by renderHydratable() and renderResponse(). You typically do not call it directly — the router handles it:

import { pageRouter, registry } from "ilha:pages/server";

// The router calls .hydratable() internally for the matched island
const html = await pageRouter.renderHydratable(
  request.url,
  registry,
);

On the client, import from ilha:pages/client (see Router — virtual modules).

For manual setups without the router, call .hydratable() directly in your SSR handler.

Full SSR + hydration example

// server.ts
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;
    ... 5 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
, { const mount: (registry: IslandRegistry, options?: MountOptions) => MountResultmount } from "ilha";
const const Counter: Island<RootInput, MergeState<RootState, "count", number>>Counter =
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;
    ... 5 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>.action<"increment", undefined, void>(key: "increment", fn: (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "count", number>, RootDerived>) => void): IlhaBuilder<RootInput, MergeState<RootState, "count", number>, RootDerived, RootActions & Record<"increment", (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "count", number>, RootDerived>) => void>>action("increment", (_: undefined_, { state: IslandState<MergeState<RootState, "count", number>>state }) => { state: IslandState<MergeState<RootState, "count", number>>state.
count: MarkedSignalAccessor
(value: SignalSetter<number>) => void (+1 overload)
count
((count: numbercount) => count: numbercount + 1);
}) .IlhaBuilder<RootInput, MergeState<RootState, "count", number>, RootDerived, RootActions & Record<"increment", (props: undefined, ctx: ActionContext<...>) => void>>.render(fn: (ctx: RenderContext<RootInput, MergeState<RootState, "count", number>, RootDerived, RootActions & Record<"increment", (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "count", number>, RootDerived>) => void>>) => string | RawHtml): Island<RootInput, MergeState<RootState, "count", number>>render(({ state: IslandState<MergeState<RootState, "count", number>>state, action: IslandActions<RootActions & Record<"increment", (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "count", number>, RootDerived>) => void>>action }) => ( <
"button": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLButtonElement> & ButtonAttributes & {
    popovertarget?: string;
    popoverTarget?: string;
    popovertargetaction?: "hide" | "show" | "toggle";
    popoverTargetAction?: "hide" | "show" | "toggle";
}>
button
onclick?: NativeEventHandler<PointerEvent & {
    readonly currentTarget: HTMLButtonElement;
}> | undefined
onclick
={action: IslandActions<RootActions & Record<"increment", (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "count", number>, RootDerived>) => void>>action.increment: ActionAccessor<undefined, void>increment}>
Count: {state: IslandState<MergeState<RootState, "count", number>>state.
count: MarkedSignalAccessor
() => number (+1 overload)
count
()}
</
"button": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLButtonElement> & ButtonAttributes & {
    popovertarget?: string;
    popoverTarget?: string;
    popovertargetaction?: "hide" | "show" | "toggle";
    popoverTargetAction?: "hide" | "show" | "toggle";
}>
button
>
)); // Server — render with snapshot const const body: stringbody = await const Counter: Island<RootInput, MergeState<RootState, "count", number>>Counter.Island<RootInput, MergeState<RootState, "count", number>>.hydratable(props: Partial<RootInput>, options: HydratableOptions): Promise<string>hydratable( { count: numbercount: 10 }, { HydratableOptions.name: stringname: "Counter",
HydratableOptions.snapshot?: boolean | {
    state?: boolean;
    derived?: boolean;
} | undefined
snapshot
: true, HydratableOptions.skipOnMount?: boolean | undefinedskipOnMount: true },
); // Client — hydrate in place function mount(registry: IslandRegistry, options?: MountOptions): MountResultmount({ type Counter: Island<RootInput, MergeState<RootState, "count", number>>Counter });

Dev warnings for non-JSON-safe snapshots

In development, if a snapshotted state or derived value contains something that does not round-trip cleanly through JSON — undefined, functions, bigint, non-finite numbers (NaN, Infinity), Date instances, and similar — ilha logs a warning identifying the offending path and reason:

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;
    ... 5 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
from "ilha";
const const Island: Island<RootInput, MergeState<RootState, "when", Date>>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;
    ... 5 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
.IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.state<Date, "when">(key: "when", init?: StateInit<RootInput, Date> | undefined): IlhaBuilder<RootInput, MergeState<RootState, "when", Date>, RootDerived, RootActions>state("when", () => new
var Date: DateConstructor
new () => Date (+4 overloads)
Date
())
.IlhaBuilder<RootInput, MergeState<RootState, "when", Date>, RootDerived, RootActions>.render(fn: (ctx: RenderContext<RootInput, MergeState<RootState, "when", Date>, RootDerived, RootActions>) => string | RawHtml): Island<RootInput, MergeState<RootState, "when", Date>>render(() => <"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p></"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>); await const Island: Island<RootInput, MergeState<RootState, "when", Date>>Island.Island<RootInput, MergeState<RootState, "when", Date>>.hydratable(props: Partial<RootInput>, options: HydratableOptions): Promise<string>hydratable({}, { HydratableOptions.name: stringname: "lossy",
HydratableOptions.snapshot?: boolean | {
    state?: boolean;
    derived?: boolean;
} | undefined
snapshot
: true });
// → warns: state.when is not JSON-safe (Date)

The snapshot still serializes — the warning is a heads-up, not a thrown error — but the restored value on the client will differ from what you set on the server (a Date becomes an ISO string, undefined fields are dropped, etc.). Keep snapshotted state and derived values plain and JSON-safe to avoid surprises on hydration. The warning is suppressed in production.

Notes

  • .hydratable() is always async — it awaits all .derived() values before rendering, regardless of whether the snapshot includes them.
  • Props are JSON-serialized into data-ilha-props. Values that are not JSON-serializable (functions, class instances, circular references) will cause a runtime error. Keep props plain and serializable.
  • The snapshot serializes signal values at the moment .hydratable() is called. If state changes after this point on the server, those changes are not reflected in the snapshot.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close