template-kit/no-client-runtime-in-server
The template-kit/no-client-runtime-in-server authoring rule — what it enforces, why, and how to fix it.
No effects, state, event handlers, network, or browser globals in server-rendered code. Interactivity is not banned — it is relocated into an island. Islands are exempt.
Whether a file is server-rendered or an island is decided per file, by the "use client"
directive — see server vs client. On an island this rule installs
no visitors at all.
In a server-rendered file it reports three things:
- React hooks that only mean something in the browser —
useEffect,useLayoutEffect,useState,useReducer,useRef,useSyncExternalStore,useImperativeHandle— called bare (useState(…)) or namespace-qualified (React.useEffect(…)). - Browser globals read as a value —
fetch,XMLHttpRequest,window,document,localStorage,sessionStorage,navigator. “Read as a value” is exact: the SSR-guard idiomtypeof window !== "undefined"is reported, whilecfg.window(a property) and{ window: 1 }(an object key) are not. - Event-handler props — any JSX prop matching
on[A-Z]…whose value is an expression:onClick={…},onChange={…},onPointerMove={…}.
Carve-outs:
- Your own definitions are yours. A name that resolves to a real local declaration —
function fetch() {}, your ownuseRefhelper — is not the global or React’s hook, and is left alone. onSurface={1}andonIcon={<Icon />}are not handlers. A prop whose value is plainly a literal or a JSX element is not reported.- A handler on an island is not reported here. If the element’s component resolves to
a real
"use client"file on disk, this rule stays quiet:serializable-island-propsalready speaks for that prop, and “move it into a client component” would be nonsense advice for something that already is one.
Reason
Section titled “Reason”A server-rendered file is run once, to a string of HTML, in a process that is not a
browser. There is no window to read and no DOM to mutate; an effect never fires, and
useState has no second render to give you. An event handler is worse than useless: it
is a function, and a function cannot be serialized into HTML, so it is silently dropped
from the published page. The button ships. The click does nothing. Nothing errors.
So a Renderer must be a pure function of its props. Interactivity is not something the
kit forbids — it is something the kit gives a specific home: a "use client" island,
which is hydrated in the browser and is exempt from every one of these bans.
Move the interactive markup into a "use client" component in the section folder, and
render it from the Renderer with plain data props. The Renderer keeps the static markup
and passes the island what it needs to do its job; the island owns the state and the
handlers.
Before
Section titled “Before”import { useState } from "react";
import { Section } from "@homepages/template-kit";
import type { Props } from "./schema.js";
export function Renderer({ slots }: Props) { const [beds, setBeds] = useState<number | null>(null); const shown = beds === null ? slots.units : slots.units.filter((u) => u.beds === beds); return ( <Section> <button onClick={() => setBeds(2)}>2 beds</button> <ul> {shown.map((u) => ( <li key={u.id}>{u.label}</li> ))} </ul> </Section> );}The state and the handler move into an island. Its props are plain data — see
serializable-island-props.
"use client";
import { useState } from "react";
type Unit = { id: string; label: string; beds: number };
export default function UnitFilter({ units }: { units: Unit[] }) { const [beds, setBeds] = useState<number | null>(null); const shown = beds === null ? units : units.filter((u) => u.beds === beds); return ( <div> <button onClick={() => setBeds(2)}>2 beds</button> <ul> {shown.map((u) => ( <li key={u.id}>{u.label}</li> ))} </ul> </div> );}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} /> </Section> );}The import carries the emitted extension (./UnitFilter.js) while the file on disk is
UnitFilter.tsx — that is TypeScript’s ESM convention, and it is what the kit’s rules
resolve against.
See also
Section titled “See also”- Server vs client — how a file is scoped, and what an island may do.
- Islands — writing one, its props contract, and its editor options.
serializable-island-props— the rule that instead reports a function-valued prop when the target is an island.