Hydratable

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: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
from "ilha";
const
const MyIsland: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
MyIsland
=
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
.
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.state<number, "count">(key: "count", init?: StateInit<Record<string, unknown>, number> | undefined): IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>
state
("count", 0).
IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>.render(fn: (ctx: RenderContext<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>) => string | RawHtml): Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
render
(({
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
}) => `<p>${
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
.
count: MarkedSignalAccessor
() => number (+1 overload)
count
()}</p>`);
const
const html: string
html
= await
const MyIsland: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
MyIsland
.
Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>.hydratable(props: Partial<Record<string, unknown>>, options: HydratableOptions): Promise<string>
hydratable
({
count: number
count
: 42 }, {
HydratableOptions.name: string
name
: "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
}
OptionTypeDefaultDescription
namestringRegistry key used by mount() to find the matching island on the client
asstring"div"Tag name for the wrapper element
snapshotboolean | objectfalseEmbed state and/or derived values in data-ilha-state
skipOnMountbooleanfalseSkip 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: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
from "ilha";
import {
const mount: (registry: IslandRegistry, options?: MountOptions) => MountResult
mount
} from "ilha";
const
const Counter: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
Counter
=
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
.
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.state<number, "count">(key: "count", init?: StateInit<Record<string, unknown>, number> | undefined): IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>
state
("count", 0).
IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>.render(fn: (ctx: RenderContext<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>) => string | RawHtml): Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
render
(({
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
}) => `<p>${
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
.
count: MarkedSignalAccessor
() => number (+1 overload)
count
()}</p>`);
const
const html: string
html
= await
const Counter: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
Counter
.
Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>.hydratable(props: Partial<Record<string, unknown>>, options: HydratableOptions): Promise<string>
hydratable
({}, {
HydratableOptions.name: string
name
: "Counter" });
// client
function mount(registry: IslandRegistry, options?: MountOptions): MountResult
mount
({
type Counter: Island<Record<string, unknown>, MergeState<Record<string, never>, "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: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
from "ilha";
const
const Counter: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
Counter
=
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
.
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.state<number, "count">(key: "count", init?: StateInit<Record<string, unknown>, number> | undefined): IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>
state
("count", 0).
IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>.render(fn: (ctx: RenderContext<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>) => string | RawHtml): Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
render
(({
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
}) => `<p>${
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
.
count: MarkedSignalAccessor
() => number (+1 overload)
count
()}</p>`);
// Snapshot state only await
const Counter: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
Counter
.
Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>.hydratable(props: Partial<Record<string, unknown>>, options: HydratableOptions): Promise<string>
hydratable
({
count: number
count
: 5 }, {
HydratableOptions.name: string
name
: "Counter",
HydratableOptions.snapshot?: boolean | {
    state?: boolean;
    derived?: boolean;
} | undefined
snapshot
: true });
// → data-ilha-state='{"count":5}' // Fine-grained control await
const Counter: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
Counter
.
Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>.hydratable(props: Partial<Record<string, unknown>>, options: HydratableOptions): Promise<string>
hydratable
(
{
count: number
count
: 5 },
{
HydratableOptions.name: string
name
: "Counter",
HydratableOptions.snapshot?: boolean | {
    state?: boolean;
    derived?: boolean;
} | undefined
snapshot
: {
state?: boolean | undefined
state
: true,
derived?: boolean | undefined
derived
: false },
}, );
snapshot valueState snapshottedDerived snapshotted
falseNoNo
trueYesYes
{ state: true, derived: false }YesNo
{ state: false, derived: true }NoYes

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: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
from "ilha";
const
const Island: Island<Record<string, unknown>, Record<string, never>>
Island
=
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
.
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.onMount(fn: (ctx: OnMountContext<Record<string, unknown>, Record<string, never>, Record<string, never>>) => (() => void) | void): IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>
onMount
(() => {
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
("this is skipped on hydration");
}) .
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.render(fn: (ctx: RenderContext<Record<string, unknown>, Record<string, never>, Record<string, never>>) => string | RawHtml): Island<Record<string, unknown>, Record<string, never>>
render
(() => `<div>hello</div>`);
await
const Island: Island<Record<string, unknown>, Record<string, never>>
Island
.
Island<Record<string, unknown>, Record<string, never>>.hydratable(props: Partial<Record<string, unknown>>, options: HydratableOptions): Promise<string>
hydratable
(
{}, {
HydratableOptions.name: string
name
: "my-island",
HydratableOptions.snapshot?: boolean | {
    state?: boolean;
    derived?: boolean;
} | undefined
snapshot
: true,
HydratableOptions.skipOnMount?: boolean | undefined
skipOnMount
: 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: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
from "ilha";
const
const Item: Island<Record<string, unknown>, Record<string, never>>
Item
=
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
.
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.render(fn: (ctx: RenderContext<Record<string, unknown>, Record<string, never>, Record<string, never>>) => string | RawHtml): Island<Record<string, unknown>, Record<string, never>>
render
(() => `<li>item</li>`);
await
const Item: Island<Record<string, unknown>, Record<string, never>>
Item
.
Island<Record<string, unknown>, Record<string, never>>.hydratable(props: Partial<Record<string, unknown>>, options: HydratableOptions): Promise<string>
hydratable
({}, {
HydratableOptions.name: string
name
: "item",
HydratableOptions.as?: string | undefined
as
: "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 } from "ilha:pages";
import { registry } from "ilha:registry";

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

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

Full SSR + hydration example

// server.ts
import 
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
, {
const html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml
html
} from "ilha";
import {
const mount: (registry: IslandRegistry, options?: MountOptions) => MountResult
mount
} from "ilha";
const
const Counter: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
Counter
=
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    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;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
.
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.state<number, "count">(key: "count", init?: StateInit<Record<string, unknown>, number> | undefined): IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>
state
("count", 0)
.
IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>.on<"button@click">(selectorOrCombined: "button@click", handler: (ctx: HandlerContextFor<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, "click", Record<string, never>>) => void | Promise<void>): IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>> (+1 overload)
on
("button@click", ({
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
}) =>
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
.
count: MarkedSignalAccessor
(value: number) => void (+1 overload)
count
(
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
.
count: MarkedSignalAccessor
() => number (+1 overload)
count
() + 1))
.
IlhaBuilder<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>.render(fn: (ctx: RenderContext<Record<string, unknown>, MergeState<Record<string, never>, "count", number>, Record<string, never>>) => string | RawHtml): Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
render
(
({
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
}) =>
const html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml
html
`
<div> <p>Count: ${
state: IslandState<MergeState<Record<string, never>, "count", number>>
state
.
count: MarkedSignalAccessor
() => number (+1 overload)
count
()}</p>
<button>Increment</button> </div> `, ); // Server — render with snapshot const
const body: string
body
= await
const Counter: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
Counter
.
Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>.hydratable(props: Partial<Record<string, unknown>>, options: HydratableOptions): Promise<string>
hydratable
(
{
count: number
count
: 10 },
{
HydratableOptions.name: string
name
: "Counter",
HydratableOptions.snapshot?: boolean | {
    state?: boolean;
    derived?: boolean;
} | undefined
snapshot
: true,
HydratableOptions.skipOnMount?: boolean | undefined
skipOnMount
: true },
); // Client — hydrate in place
function mount(registry: IslandRegistry, options?: MountOptions): MountResult
mount
({
type Counter: Island<Record<string, unknown>, MergeState<Record<string, never>, "count", number>>
Counter
});

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.