---
title: Server islands
description: 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:

```tsx
// 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](https://npmjs.com/package/oxidejs):

```ts
// 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()`:

```ts twoslash
import { useContext } from "@ilha/router";

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

:::note
In **production**, `/__ilha/frame` is **denied by default** when no guard is registered — anyone can otherwise re-render any server island by id, so if an island's state holds private data you must install a session check. `/__ilha/loader` is denied by default the same way (its output is gated behind the same `defaultAction` policy). Opt out of the deny-by-default explicitly (not recommended) by setting the frame-auth policy `defaultAction: "open"` (see below). The dev middleware stays permissive unless you register a guard.
:::

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

```ts
// dev
pages({
  frameGuard: (request) => {
    if (!isSignedIn(request))
      return new Response("Unauthorized", { status: 401 });
    // return nothing to allow the frame
  },
});
```

```ts
// 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,
});
```

:::note
Server-island render scopes do **not** forward a client-supplied `x-forwarded-for` header — it is spoofable and must not be trusted for IP checks. Frame bodies are streamed with a hard 16&nbsp;KiB cap.
:::

See [Middleware and security](/guide/routing/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:

```tsx twoslash
// src/pages/about.server.tsx
import { ilha } from "ilha";

export async function load({ params, request }: any) {
  return { path: new URL(request.url).pathname, id: params.id };
}

export default ilha.render(({ input }: any) => {
  return `<section><h1>About ${input.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](/guide/routing/routes-and-navigation#transitions)).
