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).
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 ( <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).
Props must be JSON-serializable
Section titled “Props must be JSON-serializable”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 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.
The server-rendered first frame
Section titled “The server-rendered first frame”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.
When the content is built in the browser
Section titled “When the content is built in the browser”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:
"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.
The editor canvas
Section titled “The editor canvas”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.
editor — the switch
Section titled “editor — the switch”"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.
useEditor() — the hook
Section titled “useEditor() — the hook”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”.
Pattern 2 — the inCanvas escape hatch
Section titled “Pattern 2 — the inCanvas escape hatch”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.
Painting asynchronously
Section titled “Painting asynchronously”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.
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.
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.
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 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.livehas 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 ownlivesays, so anything it must not do while an author edits is its own to gate with theinCanvasescape 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.
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";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 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.
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.
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:
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, });}See also
Section titled “See also”- Server vs client — what the
"use client"directive actually scopes, and which rules stop at it. serializable-island-props— the lint that catches a statically-visible prop violation before the render throws.require-island-editor— whylivehas no default.island-server-frame— the rule that grades aserverFramedeclaration against what the island actually server-rendered.- Add an interactive island — the same ground as a task walkthrough.
- Preview with
dev— exercising an island as you author it.