Skip to content
HomePagesHomePages template kit

Islands

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

For why a renderer ships no JavaScript and interactivity lives in a separate file, see Server and client; this page is about writing that separate file — 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.

A published page hydrates by viewport priority: the islands on screen at first paint come alive first, an island hydrates as the visitor approaches it, and the rest follow in idle slices, so hydration never janks a tap or a scroll. An island far below the fold is therefore not interactive the instant the page loads — its server-rendered markup is what the visitor sees until then, which is why a fallback has to look like the island’s first hydrated frame. template-kit dev’s preview runs the same schedule; the editor canvas hydrates every island eagerly instead, since it has to hand the editor every island at once.

Because the page schedules those bytes for you, you import a client library the way you would in any other app — install it, import it, use it. A genuinely heavy runtime (a map engine, a rich-text editor, a charting library) is a legitimate dependency of an island: there is no lazy-loading ceremony to hand-write, no <script> tag to inject from a CDN, and no flag to declare. What it costs is reported by template-kit check, which prints every section’s weight on every run, notes an island bundle over 50 KB gzip, and refuses one over 600 KB (size-island-bundle).

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 (
<ul>
{shown.map((u) => (
<li key={u.id}>{u.beds} bd</li>
))}
</ul>
);
}

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

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).

Every prop crosses the server→browser boundary as JSON — see Server and client for why props cross as data rather than as live references. What that means in practice:

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.

An island is server-rendered into the published page’s HTML, and that markup is what a visitor sees — and can use — until the island hydrates. Under viewport-priority loading that window is real for anything below the fold, so the server frame is not a placeholder waiting to be replaced: for part of every visit, it is the page. One class of island has no such frame and says so; everything below is about the rest, which is nearly all of them.

Two requirements follow, and neither is mechanically checked.

It has to work without the island. An accordion renders expanded, a carousel renders as a scroll-snap row, a lightbox trigger is a plain link to the image. The island adds behavior to content that is already there; it never supplies the content.

It has to look like the island’s first hydrated frame. The two may behave differently — a native scroll strip and a transform-driven track are not the same mechanism — but their first frames may not differ, or the page visibly jumps at the moment the island comes alive. Reproduce the hydrated resting position in CSS the server frame carries by itself: none of your code runs before that paint, so nothing can be measured or corrected in an effect. Put aria-hidden on anything that exists only to make the two agree — a duplicated slide, a spacer — so it is not announced twice.

Only a browser can settle whether they match: container queries, scroll-snap positions, and any measuring layout effect all resolve there and nowhere else. Load the published page twice, once with scripts disabled, and compare the two first frames.

Some libraries render nothing on the server by construction — a map engine draws into a canvas it creates at runtime, and there is no honest first frame to match. The requirement above cannot be met, so move the line instead: server-render your content, hydrate your behavior. The readable content stays server-rendered and complete on its own — the addresses, the list, the filters — and the engine mounts on top of it when it runs, so nothing a visitor needs to read is waiting on it.

Once the readable content has moved out, such an island can be left with nothing to server-render at all — it returns the empty box the library draws into. An island in that position may say so:

sections/neighborhood/NeighborhoodMap.tsx
"use client";
import type { IslandEditor } from "@homepages/template-kit";
import { useEffect, useRef } from "react";
export const editor = { live: true } satisfies IslandEditor;
export const serverFrame = false; // nothing to render until a browser draws it
export default function NeighborhoodMap({ center }: { center: [number, number] }) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
// The map engine creates its own canvas inside `host` and draws there.
}, [center]);
return <div ref={host} />;
}

The declaration is optional, and leaving it off is always correct. It answers one question — did you write this markup, or does a library draw it? — and an island whose markup you wrote must not carry it. An island that has no frame but stays silent simply keeps its implementation in the renderer bundle, where it costs render time and nothing else. If you wrote the markup, you are done here.

What it buys: the island’s implementation, and everything it imports, is dropped from the section’s renderer bundle. The server emits the island’s marker and nothing inside it, and the browser mounts the island fresh rather than hydrating it against markup nobody rendered. The island’s own bundle is untouched — this is about what the server loads to produce HTML, which is why a browser-only engine stops counting against size-renderer-bundle once the island declares it.

check grades the declaration against a real render in both directions: it refuses an island that declares it and renders content, and reports one that renders nothing without it (island-server-frame). An island declaring serverFrame = false must also be live: true in the editor — with no server frame and no hydration in the canvas, it would be a permanent blank hole there.

If what you want is a picture of a place rather than something to pan and zoom, do not reach for an engine at all. Render an ordinary <img> from the Renderer — a static asset when the image is the same for every deliverable, an image slot when it differs per property — and ship no island at all. A static map is a few tens of kilobytes and is correct at first paint, which is more than an interactive one can claim.

Templates are edited in a canvas where the section is re-rendered while a user edits. An island’s entire editor surface is one static export and one hook — there are no editor globals, event names, or surface declarations to learn.

"use client";
import type { IslandEditor } from "@homepages/template-kit";
export const editor = { live: true } satisfies IslandEditor;
export default function Accordion({ rows }: { rows: { id: string }[] }) { /* … */ }

live is the whole declaration, and it is required. true hydrates the island in the canvas, where it runs exactly its published behavior; false leaves it as static server-rendered HTML there. There is nothing in between — an island is on or off. It is read per top-level island; an island nested inside another follows its parent (Nesting).

It has to be a static export because the canvas decides whether to hydrate before any of your island’s code runs. It also has no default, deliberately: an island that declared nothing used to stay static, which made “I have not decided” and “static is what I want” the same code. The require-island-editor rule asks for the answer. live: false is the safe one if you are unsure.

Choose true when the island’s interaction is the content an author is arranging (a carousel, an accordion, a map). Choose false when it is not (a lead form, a mobile menu, a read-more toggle) — off solves those completely, and there is nothing further to write.

Call useEditor() from your island — it is exported by @homepages/template-kit and returns { inCanvas, selection }. inCanvas is true only inside an editor canvas. selection is what the author has selected inside this island’s own section instance, or null:

type EditorSelection =
| { kind: "section" }
| {
kind: "slot";
slotId: string;
itemIndex?: number; // the collection item the author clicked
fieldName?: string; // the field inside that item, when they clicked one
};

During server rendering and on published pages it returns { inCanvas: false, selection: null }, so it is hydration-safe by construction and costs a published page nothing. You never handle section-instance ids: a selection in some other copy of your section reads as null here. selection keeps its identity between changes, so it is safe to use directly as an effect dependency.

This hook is the only way to learn any of it. The canvas does put state on the page’s window, but those properties are host internals with no compatibility promise — your bundle is immutable and the host is not, so an island reading one goes silently inert the day it is renamed. no-editor-globals makes that unwritable.

Two patterns cover everything islands need. They are patterns, not further API.

Pattern 1 — reveal what the author selected

Section titled “Pattern 1 — reveal what the author selected”

This is the one thing the canvas asks of a live island: when an author clicks something your island keeps hidden behind an interaction, show it. Write it as an effect over selection. React gives you the mid-hydration case for free — the effect runs on mount with the current selection, and again on every change.

"use client";
import { type IslandEditor, useEditor } from "@homepages/template-kit";
import { useEffect, useState } from "react";
export const editor = { live: true } satisfies IslandEditor;
export default function Amenities({ rows }: { rows: { id: string }[] }) {
const [openId, setOpenId] = useState<string | null>(null);
const { selection } = useEditor();
useEffect(() => {
if (selection?.kind !== "slot" || selection.slotId !== "items") return;
if (selection.itemIndex === undefined) return;
setOpenId(rows[selection.itemIndex]?.id ?? null);
}, [selection]);
return <div data-open={openId} />;
}

Reveal is additive: it opens the row, pages to the card, scrolls the slide into view — on top of the published behavior, never instead of it. A carousel still loops in the canvas; an accordion still toggles. Do not fork your island into an “editor mode”.

Rarely, a live island contains one interaction that genuinely cannot run while someone is editing: a fullscreen takeover that hides the whole page the author is working on. Fork on inCanvas with an ordinary conditional — in rendering, in a handler, or as an early return in an effect:

"use client";
import { type IslandEditor, useEditor } from "@homepages/template-kit";
import { useState } from "react";
export const editor = { live: true } satisfies IslandEditor;
export default function Slider() {
const [galleryOpen, setGalleryOpen] = useState(false);
const { inCanvas } = useEditor();
const openGallery = () => {
// editor-reason: a fullscreen scroll-locking modal would cover everything being edited.
if (inCanvas) return;
setGalleryOpen(true);
};
return (
<div>
{/* editor-reason: don't advertise a takeover openGallery refuses to perform. */}
{!inCanvas && <button onClick={openGallery}>Expand</button>}
{galleryOpen && <div role="dialog"></div>}
</div>
);
}

Every fork carries a comment directly above it opening with editor-reason:, naming the published-only behavior it protects — require-editor-reason asks for it, and makes every hatch in a workspace greppable at once. Reach for this only for a takeover: if you find yourself hiding ordinary interaction from the canvas, the island probably wants live: false instead.

The dev canvas mirror implements the same contract the platform’s editor does, so an island authored once behaves the same in both.

Capture flattens what an island has painted. If your island paints everything during mount, you are done — capture waits for mounts on its own. If it keeps painting after mount — a map engine loading tiles, anything that draws when a library says so rather than when React commits — hold the shutter open:

const ready = useCaptureReady();
useEffect(() => {
const map = new mapboxgl.Map({ container: ref.current, ... });
map.once("idle", ready); // the engine's own "every pixel is painted"
return () => map.remove();
}, [ready]);

Calling the hook takes the hold; calling ready() releases it. It is idempotent, releases itself on unmount, and a branch with nothing to draw calls ready() immediately — taking the hold means owning every path. template-kit dev enforces this: a capture that keeps painting after it reported ready fails naming the island and this hook.

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.

A frameless island’s marker is empty and carries a fourth attribute, value-less, saying so:

<tr-island style="display:contents" data-tr-island="<key>" data-tr-island-id="<id>" data-tr-island-frameless></tr-island>

That attribute is how the browser reads the DOM alone and knows to mount the island rather than hydrate it against markup that was never emitted.

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 under the section’s root in a marker, the nested island’s marker ends up inside the parent island’s server-rendered output.

Only files under the section root are islands. A "use client" component the section imports from elsewhere in the workspace (a template-level components/ folder, say) is implementation bundled into the importing island — it gets no marker of its own, in the preview and on the published page alike. To make such a shared component an island of a section, re-export it from a "use client" file inside that section; the re-export file is its one marker.

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 own editor.live 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. A nested island hydrates in the canvas whenever its parent is live, whatever its own live says, so anything it must not do while an author edits is its own to gate with the inCanvas escape hatch, not the flag. useEditor() works there too, and reports the parent root’s own section instance.
  • 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";
import type { IslandModule } from "@homepages/template-kit/island-runtime";
declare const ISLANDS: Record<string, () => Promise<IslandModule>>;
declare const sectionEl: Element;
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.

Hydrating and mounting are two different moments. The call resolves once the islands it covers have React roots, and React commits those roots a task later — so the DOM an island renders does not exist yet when it resolves. A host that needs the islands really on the page (a screenshot, a measuring pass) awaits mounted on each handle the call returns: it settles once React has committed that island and the state its mount effects set, and it never rejects, so an island that failed cannot leave the host waiting.

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

const result = await submitLead({ email, name });
if (!result.ok) showError(result.message);

See Add a lead form 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,
});
}