Fresh logo

Islands

Islands enable client side interactivity in Fresh. An island is an isolated Preact component that is rendered on the server like any component, and then hydrated on the client so it can respond to user input. Everything outside an island stays static HTML. No JavaScript is shipped for it.

Islands are defined by creating a file in the islands/ folder in a Fresh project. The exported component is a normal Preact component.

Typescript islands/Counter.tsx
import { useSignal } from "@preact/signals";

export function Counter() {
  const count = useSignal(0);

  return (
    <button type="button" onClick={() => count.value++}>
      Count: {count}
    </button>
  );
}

An island can be used in a page like a regular Preact component. Fresh detects that it is an island and takes care of hydrating it automatically.

Typescript routes/index.tsx
import { page } from "./$index.ts";
import { Counter } from "../islands/Counter.tsx";

export default page(() => (
  <main>
    <h1>Welcome</h1>
    <Counter />
  </main>
));

Islands can also live next to the routes that use them. Place them in a co-located (_islands)/ folder inside routes/. See Routing for the conventions.

Passing props to islands

Passing props to islands is supported, but only if the props are serializable. Props cross from the server to the client, so Fresh has to encode them into the page payload. See Serializable types for the full reference. The short version is that Fresh can serialize the following kinds of values:

  • Primitives: string, number, boolean, bigint, null, and undefined
  • The special numeric values NaN, Infinity, and -Infinity
  • Date, RegExp, Map, and Set
  • Plain objects and arrays of serializable values
  • Circular and shared references (serialized once, restored as one instance on the client)
  • Preact Signals, if their inner value is serializable
  • Serializable functions — plain functions exported from an island file, or factory(...) wrappers exported from one
  • JSX elements, passed as a slot (see below)
Typescript routes/index.tsx
import { greet } from "../islands/actions.tsx";

<Counter start={3} label="Clicks" tags={new Set(["a", "b"])} onGreet={greet} />;

Plain inline closures defined in a route are not serializable, and neither are class instances of unrecognized classes or other arbitrary objects. Passing one throws at render time. See Serializable types for the full set of rules and for how to define your own serializable functions.

Signals stay reactive across islands

A signal passed as a prop is the same reactive signal on the client. This means a parent can own a piece of state and share it across multiple islands. Both islands receive the same count and stay in sync.

Typescript routes/index.tsx
import { page } from "./$index.ts";
import { useSignal } from "@preact/signals";
import { Display } from "../islands/Display.tsx";
import { Controls } from "../islands/Controls.tsx";

export default page(() => {
  const count = useSignal(0);
  return (
    <>
      <Display count={count} />
      <Controls count={count} />
    </>
  );
});

See Signals & reactivity.

Passing JSX to islands

Islands support passing JSX as a “slot”. The static content is rendered on the server and the client splices it back into the island after hydration. This lets you mix server rendered markup into the interactive part of the page without shipping JavaScript for it.

Typescript islands/Card.tsx
import { ComponentChildren } from "preact";
import { useSignal } from "@preact/signals";

interface Props {
  children: ComponentChildren;
}

export function Card({ children }: Props) {
  const open = useSignal(true);

  return (
    <div>
      <button onClick={() => (open.value = !open.value)}>{open.value ? "Hide" : "Show"}</button>
      {open.value && <div class="card">{children}</div>}
    </div>
  );
}
Typescript routes/index.tsx
import { page } from "./$index.ts";
import { Card } from "../islands/Card.tsx";

export default page(() => (
  <Card>
    <p>This text is rendered on the server.</p>
  </Card>
));

Nesting islands

There are two ways one island can end up “inside” another, and they behave differently.

Rendered in the outer island’s component body. The outer island imports the inner island and uses it as part of its own JSX. The two run together as a single Preact tree on the client. Only the outer island is a hydration root. The inner island acts as a regular Preact component, and its props are just JavaScript values in the outer island’s runtime, not serialized separately.

Typescript islands/Card.tsx
import { ComponentChildren } from "preact";
import { useSignal } from "@preact/signals";
import { Counter } from "./Counter.tsx";

export function Card({ children }: { children: ComponentChildren }) {
  const open = useSignal(true);
  return (
    <div>
      <button onClick={() => (open.value = !open.value)}>Toggle</button>
      {open.value && (
        <div class="card">
          <Counter />
          {children}
        </div>
      )}
    </div>
  );
}

Passed to the outer island as a slot. The inner island appears in a route file as a child (or other JSX prop) of the outer island. The slot is rendered on the server and spliced into the outer island’s DOM after it hydrates. Preact for the outer island does not control that subtree, so the inner island is a separate hydration root with its own serialized props.

Typescript routes/index.tsx
import { page } from "./$index.ts";
import { Card } from "../islands/Card.tsx";
import { Counter } from "../islands/Counter.tsx";

export default page(() => (
  <Card>
    <Counter start={5} />
    <p>Some more server rendered text.</p>
  </Card>
));

In the first case the outer island bundle carries the inner island’s code, and you can pass anything through props since nothing crosses the boundary again. In the second case the inner island ships its own bundle and its props go through the serializer.

Rendering on the client only

When using client-only APIs like EventSource or navigator.getUserMedia, an island that calls them at render time will fail on the server with something like ReferenceError: EventSource is not defined. Guard the browser-only branch with the IS_BROWSER flag from fresh/runtime.

Typescript islands/Live.tsx
import { IS_BROWSER } from "fresh/runtime";

export function Live() {
  if (!IS_BROWSER) return <div>Loading…</div>;

  // Everything here runs only in the browser.
  const source = new EventSource("/events");
  // ...
  return <div>Live!</div>;
}