Skip to content
HomePagesHomePages template kit
Menu

template-kit/serializable-island-props

The template-kit/serializable-island-props authoring rule — what it enforces, why, and how to fix it.

Props handed to an island must be JSON-serializable. This rule is about what a file hands to an island, so it has no island exemption — it runs everywhere.

The rule looks at every JSX element whose component was imported from a file that is a real "use client" module on disk. On those elements — and only those — it reports any prop value it can statically see to be un-JSON-able:

  • a literal function — an arrow or a function expression (onSelect={() => …});
  • new Anything()new Date(), new Map(), new Set(), new URL(…), new RegExp(…), new YourClass(). Everything except new Object() and new Array(), which are plain and cross fine;
  • a bigint literal10n;
  • NaN and Infinity — JSON has neither.

It recurses into object and array literals, and names the exact path it found: props.nested.deep.cb, props.units[0].created.

Carve-outs:

  • A getter or setter in an object literal is skipped. An accessor reads a value back out; it never receives one across the boundary.
  • A value handed over as a plain identifier is invisible. onPick={pick} reports nothing — the rule cannot see what pick is. That case is deliberately left to the runtime check, which throws on it. The lint catches what it can see, early; the runtime catches the rest.
  • This rule stops at the island boundary — the preset does not. <UnitTable onSelect={() => {}} /> is not reported by this rule, because UnitTable is not an island and no prop crosses a serialization boundary. But inside a server-rendered Renderer, that same line is still reported — by no-client-runtime-in-server, which bans any on* JSX prop carrying a function on a non-island target, because the handler is dropped from the published HTML regardless of what it’s attached to. Moving a handler off an island and onto an ordinary component doesn’t clear the violation — it trades this rule’s error for that one.

Props are JSON on the wire. An island is server-rendered inline with the page, and then its props are emitted next to it as a JSON script tag, which the browser parses back before hydrating. So a prop is not passed to the island — a JSON round-trip of the prop is.

A function does not survive that trip. Neither does a Date (it becomes a string), a Map (it becomes {}), or NaN (it becomes null). The runtime refuses rather than shipping a corrupted prop: it throws IslandPropsError, naming the path. This rule says the same thing in your editor, in the same words, before the render ever runs.

Pass data, not behavior. An island owns its own handlers, so it does not need yours. A Date/Map/Set/RegExp crosses as its plain-JSON equivalent — an ISO string, an array of entries — which you reconstruct inside the island. For NaN/Infinity, pass a finite number, or encode the sentinel as a string prop the island parses.

That reconstruction step assumes the value is backed by real data. A Date built from a literal — new Date(2020, 0, 1), with no slot behind it — has no reason to be a Date object on the server at all: write the ISO string (or just the year, or whatever the island actually needs) as a plain literal prop, and skip the round-trip entirely.

sections/floorplan/Renderer.tsx
import { Section } from "@homepages/template-kit";
import UnitFilter from "./UnitFilter.js";
import type { Props } from "./schema.js";
export function Renderer({ slots }: Props) {
return (
<Section>
<UnitFilter
units={slots.units}
onSelect={(id: string) => console.log(id)}
created={new Date()}
/>
</Section>
);
}

The handler moves inside the island, where it belongs. The date crosses as an ISO string and is rebuilt on the other side.

sections/floorplan/Renderer.tsx
import { Section } from "@homepages/template-kit";
import UnitFilter from "./UnitFilter.js";
import type { Props } from "./schema.js";
export function Renderer({ slots }: Props) {
return (
<Section>
<UnitFilter units={slots.units} createdIso={slots.listed_at} />
</Section>
);
}
sections/floorplan/UnitFilter.tsx
"use client";
import { useState } from "react";
type Unit = { id: string; label: string };
export default function UnitFilter({ units, createdIso }: { units: Unit[]; createdIso: string }) {
const [selected, setSelected] = useState<string | null>(null);
const created = new Date(createdIso); // reconstructed inside the island
return (
<div>
<p>Listed {created.getFullYear()}</p>
<ul>
{units.map((u) => (
<li key={u.id}>
<button onClick={() => setSelected(u.id)}>{u.label}</button>
</li>
))}
</ul>
{selected !== null && <p>Selected {selected}</p>}
</div>
);
}