Skip to content
Ilha
Esc
navigateopen⌘Jpreview
On this page

Server islands

Render islands on the server with .server.tsx files, keep them live through frames, and read the originating request.

Define islands in *.server.ts(x) files. The render function never ships to the browser — the client hydrates a proxy that replays actions over RPC and re-renders through the server:

// src/lib/tasks.server.tsx
export const TaskList = ilha
  .stream("items", ({ signal }) => getTasks({ signal }))
  .action("remove", (id: string) => deleteTask(id))
  .render(({ state, action }) => (
    <ul>
      {each(state.items() ?? []).as((task) => (
        <li
          key={task.id}
          onclick={() => action.remove(task.id)}
        >
          {task.text}
        </li>
      ))}
    </ul>
  ));

Import <TaskList /> in any page. The plugin scans the module, generates a client proxy, and keeps the render function server-side.

The proxy stays current through frames: the client posts to /__ilha/frame, and the server re-renders the island from its own state and morphs the HTML into place. In dev the plugin serves the endpoint; in production, wire it into your server’s fetch handler. With oxidejs:

// vite.config.ts — the middleware serves both SSR endpoints in production:
// POST /__ilha/frame (server islands) and GET /__ilha/loader (client-
// navigation data for regular pages). Its route-graph imports are implicit.
oxide({ middleware: ["@ilha/router/ssr"] });

Render functions read the originating request through useContext():

import { useContext } from "@ilha/router";

// inside a .server.tsx render function
const { request } = useContext();

Gate frames in dev through the plugin option, and in production through the shared guard slot:

// dev
pages({
  frameGuard: (request) => {
    if (!isSignedIn(request))
      return new Response("Unauthorized", { status: 401 });
    // return nothing to allow the frame
  },
});
// production — call once at server startup
import {
  setFrameAuth,
  setFrameGuard,
  setLoaderGuard,
} from "@ilha/router/server-island-registry";

// Gates /__ilha/frame in production; without this the endpoint returns 403.
setFrameGuard((request) => {
  if (!isSignedIn(request))
    return new Response("Unauthorized", { status: 401 });
});

// Gates /__ilha/loader independently (falls back to the frame guard when absent).
setLoaderGuard((request) =>
  isSignedIn(request)
    ? undefined
    : new Response("Unauthorized", { status: 401 }),
);

// Optional: explicit trusted origins (origin checks otherwise compare the
// Origin header against the request's own Host) and a CSRF verifier for the
// frame POST (covers non-browser callers that send no Origin).
setFrameAuth({
  trustedOrigins: ["https://app.example.com"],
  csrf: (request) =>
    request.headers.get("x-csrf") === process.env.CSRF_SECRET,
});

See Middleware and security for the full guard and origin model.

Server pages — foo.server.tsx

A .server.tsx file in the pages directory makes the whole route server-rendered. The page must export a default island. Use the server load for data fetching — it runs at frame time with matched route params, and its return value becomes the island’s props:

// src/pages/about.server.tsx
import { 
const ilha: RootBuilder & DirectIslandFactory & {
    html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
    raw: (value: string) => RawHtml;
    mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
    from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
    ... 6 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
} from "ilha";
export async function
function load({ params, request }: any): Promise<{
    path: string;
    id: any;
}>
load
({ params: anyparams, request: anyrequest }: any) {
return { path: stringpath: new var URL: new (url: string | URL, base?: string | URL) => URL
The **`URL`** interface is used to parse, construct, normalize, and encode URLs. It works by providing properties which allow you to easily read and modify the components of a URL. [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL)
URL
(request: anyrequest.url).URL.pathname: string
The **`pathname`** property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character. [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)
pathname
, id: anyid: params: anyparams.id };
} export default
const ilha: RootBuilder & DirectIslandFactory & {
    html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
    raw: (value: string) => RawHtml;
    mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
    from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
    ... 6 more ...;
    onUncaughtError: typeof onUncaughtError;
}
ilha
.IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.render(fn: (ctx: RenderContext<RootInput, RootState, RootDerived, RootActions>) => string | RawHtml): Island<RootInput, RootState>render(({ input: anyinput }: any) => {
return `<section><h1>About ${input: anyinput.path}</h1></section>`; });

The client mounts a proxy island that immediately pulls its HTML from /__ilha/frame — with the current path attached, so load sees the same params and query on every frame. Loader redirect()s are honored: the client navigates to the target. No SSR host entry is needed; this works in a plain oxidejs SPA deployment. Layouts and +error boundaries still hydrate client-side around the server island.

loader.client also works on server pages — it executes over RPC when the view hydrates (the code never ships to the browser). It is a side-effect loader there: set document state, fire analytics. Its return value cannot flow back into server-rendered island markup.

During client navigation to a server page the view host starts empty until the first frame lands — pair it with a pending indicator (see Transitions).

Was this page helpful?