Skip to content
HomePagesHomePages template kit
Menu

Islands: interactivity in a template

How interactivity works in a template — client components ("islands"), their props contract, and their editor options.

A section’s Renderer.tsx is server-rendered and ships no JavaScript. When part of a section needs to be interactive — a filter, a slider, a lightbox — you write an ordinary React component in the section folder and mark it "use client". It is server-rendered with the rest of the page, then hydrated in the browser as its own React root. That component is an island.

If a page contains no islands, it loads no React and no island code — every page still ships the small island loader (a few KB, gzipped), but that loader only ever reaches React through a dynamic import it takes when an island is actually on the page.

template-kit dev hydrates a section’s islands in its preview, so you can exercise one as you author it.

sections/floorplan/UnitFilter.tsx
"use client";
import { useState } from "react";
export default function UnitFilter({ units }: { units: { id: string; beds: number }[] }) {
const [beds, setBeds] = useState<number | null>(null);
const shown = beds === null ? units : units.filter((u) => u.beds === beds);
return ( /* … */ );
}

Use it from the Renderer like any other component. The build wraps it in an island marker and hydrates it on the published page:

import UnitFilter from "./UnitFilter.js";
export function Renderer({ units }: Props) {
return <UnitFilter units={units} />;
}

Rules:

  • The component is default-exported. One island per "use client" file.
  • Effects, state, event handlers, and browser APIs are all fine inside an island — it runs in the browser. They are not fine in a Renderer, which must stay a pure function of its props.
  • Third-party client libraries are fine inside an island (a slider, a map SDK).

Everything the Renderer passes to an island crosses a server→browser boundary as JSON:

Allowed Rejected
string, number (finite), boolean, null functions, including event handlers
plain objects, arrays, nested combinations of the above Date, Map, Set, BigInt, symbols, class instances
undefined values, per JSON: an object property is dropped, an array entry becomes null NaN, Infinity

A rejected prop fails the render, naming the path:

IslandPropsError: props.units[0].created is a Date — island props must be
JSON-serializable (plain objects and arrays only)

Pass an ISO string and parse it inside the island. Pass data, not callbacks — an island owns its own handlers.

The template-kit/serializable-island-props lint rule catches a statically-visible violation (a literal arrow function, new Date(), a bigint literal, NaN/Infinity) in your editor, before the render ever throws — a value assembled at runtime and handed over as an identifier is invisible to the rule and stays this runtime check’s job.

Templates are edited in a canvas where the section is re-rendered on every keystroke. Every island must declare how it behaves there:

  • liverequired. true hydrates the island in the editor canvas behind the selection gate; false leaves it as static SSR HTML there.
  • shieldMode, liveSurface, props — optional, and only meaningful when live: true.
"use client";
import type { IslandEditor } from "@homepages/template-kit";
export default function Accordion({ items, singleOpen }: Props) { /* … */ }
export const editor = {
live: true, // required — hydrate in the editor canvas
shieldMode: "fill-parent", // how the editor sizes its selection shield
liveSurface: (root) => [...root.querySelectorAll(".accordion")], // shield only these
props: { singleOpen: true }, // editor-only props, merged over the real ones
} satisfies IslandEditor;

props merge in as an update immediately after the island hydrates, not as part of its first mount — so an island must read them during render. A value read only at mount time (a useState initializer, an <input defaultValue>) sees the real prop, and the editor-only override never reaches it.

There is no default, deliberately. An island that declared nothing used to stay static in the canvas, which made “I have not decided” and “static is what I want” the same code. The require-island-editor rule now asks for the answer. live: false is the safe one if you are unsure — it is what an undeclared island already did, written down.

The minimal declaration carries just the required field:

"use client";
export const editor = { live: true };
export default function Lightbox({ headline }: { headline: string }) {
// …
}

Rarely needed while authoring, but this is what the build emits, and what a host hydrates:

<tr-island style="display:contents" data-tr-island="<key>" data-tr-island-id="<id>">
…server-rendered output…
</tr-island>
<script type="application/json" data-tr-island-props="<id>">{"units":[…]}</script>

The marker is layout-invisible (display:contents), so an island can sit directly inside a grid or flex container without adding a box.

An island can render another island — for example, an accordion island whose expanded content is itself an existing expandable-text island. Because the build wraps every "use client" module in a marker, the nested island’s marker ends up inside the parent island’s server-rendered output.

A nested marker is not hydrated as its own root. It is left to the marker that contains it, which already renders — and hydrates — it as part of its own React tree. The nested component still runs and stays interactive, but it loses its own root and its own editor treatment:

  • Its editor.live/shieldMode/liveSurface/props declaration has no independent effect in the canvas — the parent island’s declaration governs the whole subtree, since there is no separate root for the editor to select or shield.
  • Its props <script> still renders in the DOM (harmless) but goes unread; props reach the nested component through the parent’s own render instead.

@homepages/template-kit/island-runtime is the loader. It is not something a template author calls — it is what a page (or the editor) calls to bring islands to life:

import { hydrateIslands, unmountIslands } from "@homepages/template-kit/island-runtime";
await hydrateIslands({ load: (key) => ISLANDS[key]() }); // ISLANDS: the build's island map
unmountIslands(sectionEl); // before replacing sectionEl's HTML

Each top-level island is an independent React root: one island’s state, re-renders, and errors never touch another’s. An island nested inside another (see above) hydrates as part of its parent’s root instead. Hydration is idempotent, so calling hydrateIslands again after injecting new markup is safe.

To collect a visitor’s contact details, call submitLead — it owns the entire request, including signing, retry, and source attribution:

import { submitLead } from "@homepages/template-kit/browser";
const result = await submitLead({ email, name });
if (!result.ok) showError(result.message);

See the lead form recipe for the full island.

For a signed request to some other HomePages endpoint, the same entry exports the primitives underneath it — reading the page’s embedded site config, and signing a body:

import { readSiteConfig, signedIngestHeaders } from "@homepages/template-kit/browser";
const config = readSiteConfig();
if (config) {
// Sign and send the SAME string — the signature covers those exact bytes.
const body = JSON.stringify({ event: "viewed" });
await fetch(`${config.ingestBase}/analytics/ingest`, {
method: "POST",
headers: await signedIngestHeaders(config, body),
body,
});
}