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) => URLThe **`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: stringThe **`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).