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.
Writing one
Section titled “Writing one”"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).
Props must be JSON-serializable
Section titled “Props must be JSON-serializable”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 beJSON-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.
Editor options
Section titled “Editor options”Templates are edited in a canvas where the section is re-rendered on every keystroke. Every island must declare how it behaves there:
live— required.truehydrates the island in the editor canvas behind the selection gate;falseleaves it as static SSR HTML there.shieldMode,liveSurface,props— optional, and only meaningful whenlive: 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 }) { // …}The DOM contract
Section titled “The DOM contract”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.
Nesting one island inside another
Section titled “Nesting one island inside another”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/propsdeclaration 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.
Hosting islands yourself
Section titled “Hosting islands yourself”@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 mapunmountIslands(sectionEl); // before replacing sectionEl's HTMLEach 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.
Talking to HomePages from an island
Section titled “Talking to HomePages from an island”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, });}