Server Islands
A server island is a small server-rendered part of a page that renders per request, with the visitor's cookies and middleware context, even when the page around it is prerendered and cached for everyone.
Overview
An ssg or isg page is rendered once and shared by every visitor, so it
cannot say "Signed in as Ada" or show Ada's cart count. A server island renders
that personal part per request while the rest of the page stays cached.
import {
useServerIslandData,
type ServerIslandLoaderArgs,
type ServerIslandProps,
} from "@pracht/core";
import { cartCount } from "../server/cart.ts";
export async function loader({ context, signal }: ServerIslandLoaderArgs) {
if (!context.user) return { count: 0 };
return { count: await cartCount(context.user.id, { signal }) };
}
export default function CartCount({ label }: { label: string } & ServerIslandProps) {
const { count } = useServerIslandData<typeof loader>();
return (
<a href="/cart">
{label} ({count})
</a>
);
}Import it into any page or shell, list it in that module's serverIslands
export, and use it as plain JSX with a fallback:
import CartCount from "../server-islands/CartCount.tsx";
export const serverIslands = [CartCount];
export function Shell({ children }: ShellProps) {
return (
<>
<header>
<CartCount label="Cart" fallback={<a href="/cart">Cart</a>} />
</header>
<main>{children}</main>
</>
);
}Every route using that shell can stay ssg or isg. Their HTML is the same for
every visitor, and each visitor sees their own cart count.
Authoring
Every module in src/server-islands/ (configurable with
pracht({ serverIslandsDir })) is a server island, so keep helper modules
elsewhere. The default export is the component. The optional loader
receives the page's request, url, params, signal, the context your
middleware built, the app root state as root, and the server
island's props. Read its return value with
useServerIslandData<typeof loader>().
A route or shell lists every server island it renders in
export const serverIslands = [...], including ones rendered by components it
uses. The shell's list covers every route under it. Rendering a server island
the page does not list fails the render with an error naming the module to add
it to; on an spa route, the dev console warns instead. A server island
rendered inside another server island needs no listing.
fallback is what the page shows until the server island arrives. The page
keeps it when the server island fails, or when middleware or the loader answers
with a Response such as redirect(). It never reaches your component.
Server islands take no children. Their props follow the rules for
island props: strings, finite numbers,
booleans, null, arrays, and plain objects. The loader's return value can be
anything, because it is only rendered on the server.
What Visitors See
| Page | The document contains | Then |
|---|---|---|
render: "ssr" |
The server island's HTML | Nothing more to load |
render: "ssg" / "isg" |
The fallback | The browser fetches the server island after load |
ssr with streaming: true |
The fallback | The browser fetches the server island after load |
| Client navigation (full hydration) | The fallback | The server island is fetched when it mounts |
On an ssr page, the server island loaders run at the same time, and the
response waits for all of them, so the slowest loader decides when the page
arrives. With streaming: true, the page is sent without waiting and its server
islands are fetched after load.
On hydration: "none" and "islands" pages, a small script (about 1.3 KB
gzip, no Preact) fills the server island, and only pages that render a pending
server island load it. Islands inside a server island hydrate once its HTML is
in place. A server island passed to an island as children is fetched when the
island first shows those children. With
islandsNavigation,
a swapped-in page fills its server islands the same way, and an island that
stays mounted across the swap keeps the server island HTML it already has.
On hydration: "full" pages, the server island's markup is left alone by
hydration and re-renders, so it cannot cause a hydration mismatch. It fetches
fresh HTML after useRevalidate(), a successful <Form> submission, or a
capability call that changes data, so a cart count follows the cart. Islands
inside a server island stay static HTML on these pages.
A server island's code and its loader never reach the browser. Only the stylesheets it imports do.
Security
A server island runs with the middleware of the page that renders it, and the
browser asks for it with the page path and props in a URL any caller can edit.
Server island props are untrusted input: authorize from context inside the
loader, as the if (!context.user) check above does.
A server island runs only for pages whose route or shell lists it, under that
page's middleware, but the list is not an authorization check. Anyone who passes
a page's middleware can request every server island that page lists, including
one it renders only conditionally, such as {isAdmin && <AdminStats />}. A
loader that returns private data checks context itself.
Server island responses are always Cache-Control: private, no-store, so the
page around them stays cacheable.
Errors and Deployment
A failing server island never fails its page. The page shows the fallback, and
the error goes to your onRouteError hook with the server island file named.
Server islands need a server, so they work on the Node, Cloudflare, Netlify,
and Vercel adapters. With @pracht/adapter-static, a prerendered page that
renders a server island fails the build. Load per-visitor content from an
island there instead.