Skip to content
HomePagesHomePages template kit
Menu

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 browseruseEffect, useLayoutEffect, useState, useReducer, useRef, useSyncExternalStore, useImperativeHandle — called bare (useState(…)) or namespace-qualified (React.useEffect(…)).
  • Browser globals read as a valuefetch, XMLHttpRequest, window, document, localStorage, sessionStorage, navigator. “Read as a value” is exact: the SSR-guard idiom typeof window !== "undefined" is reported, while cfg.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 own useRef helper — is not the global or React’s hook, and is left alone.
  • onSurface={1} and onIcon={<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-props already speaks for that prop, and “move it into a client component” would be nonsense advice for something that already is one.

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.

sections/floorplan/Renderer.tsx
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.

sections/floorplan/UnitFilter.tsx
"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>
);
}
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} />
</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.

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