Islands
Islands let mostly static routes hydrate only the components that need browser interactivity. The rest of the document stays server-rendered HTML with little or no JavaScript.
Overview
A docs page might need only a search box, and a pricing page only a calculator. Islands hydration renders the full HTML on the server and ships JavaScript only for those interactive widgets.
route("/", "./routes/home.tsx", {
render: "ssg",
hydration: "islands",
});hydration is separate from render:
| Hydration mode | Client JavaScript | Best for |
|---|---|---|
"full" |
Full route tree and client router | App-like pages and existing routes |
"islands" |
Islands bootstrap plus rendered islands | Static content with a few widgets |
"none" |
No framework JavaScript | Fully static pages |
render: "spa" always uses full hydration. Islands work with ssg, isg,
and ssr routes.
Create an Island
Put interactive components in src/islands/. Pracht auto-discovers default and
named component exports from that directory.
import { useState } from "preact/hooks";
import type { IslandProps } from "@pracht/core";
interface CounterProps {
start?: number;
}
export default function Counter({ start = 0 }: CounterProps & IslandProps) {
const [count, setCount] = useState(start);
return (
<button type="button" onClick={() => setCount((value) => value + 1)}>
Count: {count}
</button>
);
}Use the island from a route like a normal component:
import Counter from "../islands/Counter.tsx";
export function Component() {
return (
<main>
<h1>Mostly static</h1>
<p>This content renders as HTML and never hydrates.</p>
<Counter start={5} />
</main>
);
}On an islands route, the server wraps Counter in a marker, serializes its
props, and the browser hydrates only that component.
Loading Strategies
Set the framework-owned client prop per island usage:
<Counter start={5} />
<SearchBox client="idle" />
<NewsletterSignup client="visible" />| Strategy | Behavior |
|---|---|
load |
Hydrates immediately and is modulepreloaded. This is the default. |
idle |
Hydrates when the browser is idle. |
visible |
Hydrates after the island scrolls into view. |
idle and visible islands are not preloaded, so below-the-fold widgets
fetch their chunks only when needed.
A strategy defers an island's JavaScript, never its styles: every rendered island's CSS is in the page's document. See Styling.
Props and Children
Island props are embedded in the HTML and revived with JSON.parse, so they
must be JSON-serializable: strings, finite numbers, booleans, null, arrays,
and plain objects.
Do not pass functions, class instances like Date, JSX elements, symbols,
bigints, or circular objects as island props. Pracht throws an error that names
the invalid prop path.
To wrap server content in an interactive component, pass it as children. The
island renders children wherever it likes, as with any component:
import type { ComponentChildren } from "preact";
import { useState } from "preact/hooks";
import type { IslandProps } from "@pracht/core";
interface DisclosureProps {
summary: string;
children?: ComponentChildren;
}
export default function Disclosure({ summary, children }: DisclosureProps & IslandProps) {
const [open, setOpen] = useState(false);
return (
<div>
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)}>
{summary}
</button>
{open ? children : null}
</div>
);
}import type { RouteComponentProps } from "@pracht/core";
import Counter from "../islands/Counter.tsx";
import Disclosure from "../islands/Disclosure.tsx";
export async function loader() {
return { entries: ["Islands take children", "Faster builds"] };
}
function Changelog({ entries }: { entries: string[] }) {
return <ul>{entries.map((entry) => <li key={entry}>{entry}</li>)}</ul>;
}
export function Component({ data }: RouteComponentProps<typeof loader>) {
return (
<Disclosure summary="Show the full changelog">
<Changelog entries={data.entries} />
<Counter start={0} />
</Disclosure>
);
}Changelog renders on the server and its code never reaches the browser. The
island receives that HTML as children, so showing, hiding, or moving them
keeps the same nodes. An island inside them, like Counter here, hydrates on
its own and keeps its state when the outer island hides and shows it. Children
the island does not render on the server, such as a closed disclosure's, still
ship with the page, inert: their images load and their scripts run when the
island first shows them.
Plain text children are different: <CopyButton>npm i {pkg}</CopyButton>
gives the island one string, such as "npm i pracht", so it can use it as a
value.
Markup children are rendered once, on the server. The island cannot pass them props or change their contents, and a render function as children throws an error. Context the island provides does not reach them in the browser, so an island among them reads the context's default value; pass the value as a prop, and watch for the dev warning that flags this.
Markup children reach the island as one opaque node, however many elements the
page passed, so toChildArray(children) or Children.count cannot split them
into tabs or slides. Pass what the island needs to know as props, and reach the
rendered elements through a ref. Here the page marks each panel and the island
toggles their hidden attribute:
import type { ComponentChildren } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
export default function Tabs({ labels, children }: { labels: string[]; children?: ComponentChildren }) {
const [active, setActive] = useState(0);
const panels = useRef<HTMLDivElement>(null);
useEffect(() => {
panels.current!.querySelectorAll<HTMLElement>("[data-panel]").forEach((panel, i) => {
panel.hidden = i !== active;
});
}, [active]);
return (
<div>
{labels.map((label, i) => (
<button key={label} type="button" aria-pressed={i === active} onClick={() => setActive(i)}>
{label}
</button>
))}
<div ref={panels}>{children}</div>
</div>
);
}<Tabs labels={["Overview", "Specs"]}>
<section data-panel>...</section>
<section data-panel hidden>...</section>
</Tabs>Markup children arrive inside a <pracht-slot> element with
display: contents (an SVG <g> inside SVG). A child selector such as
.panel > p written against the island's markup no longer matches them, and
:first-child or :nth-child() count from the first child the page passed,
not among the island's own elements.
Some positions cannot hold that element: directly inside table rows and
sections, <video>, <audio>, <picture>, <ruby>, SVG <text>, gradients,
or <clipPath>, or a MathML <mfrac>; anywhere in <select> or <textarea>;
or a <summary> or <legend> that has to come first. Pracht throws an error
naming the island there; pass the whole element as children instead. Children
an island shows inside SVG or MathML must already be rendered there on the
server.
The browser also rearranges invalid nesting: a <div> in children placed
inside a <p>, an <a> or <button> inside another, or raw HTML in
dangerouslySetInnerHTML that is not well-formed. In development, Pracht
throws an error naming the island for the nesting it can see. If children are
moved out of their slot anyway, the island stays server-rendered HTML instead
of hydrating, and the console logs an error naming it. Pracht finds the slot's
end by an HTML comment, so an HTML minifier must keep comments.
Island props are fixed when the page renders, so on an ssg or isg page they
are the same for every visitor. For content that depends on who is asking, such
as a cart count, render a server island instead.
Navigation
Islands routes do not load the client router, so by default navigation to,
from, and between them is a normal full-document navigation. That includes
links from a full-hydration route to an islands or hydration: "none" route.
With defineApp({ viewTransitions: true }), these full page loads still
animate as
cross-document view transitions,
without adding JavaScript.
Client-side navigation between islands pages
Turn on islandsNavigation to keep the document when a link goes from one
islands page to another:
pracht({
adapter: nodeAdapter(),
client: { islandsNavigation: true },
});The islands bootstrap then fetches the next page's HTML and swaps it in. The
URL, title, and stylesheets change. An island that both pages render with the
same props and the same children, such as a cart button in a shared shell, stays mounted and keeps
its state and focus. Anywhere else, focus starts over at the top of the new
page, as after a page load. New islands hydrate with their own client strategy. Back and
forward restore the earlier page and its scroll position. With
viewTransitions on, the swap animates as a same-document view transition.
Links to anything else load a new document as before, and are never fetched
first: a full-hydration route, an API route, a URL outside your app, a page
the browser has prerendered from your speculation rules, and any link marked
<a data-pracht-reload>.
Scripts the new page shares with the old one do not run again, so a page-view
counter that fires on load sees only the first page. Count the rest from the
Navigation API's navigatesuccess event.
When pages still load normally
A swapped-in page runs under the security headers the document was first
loaded with, so the bootstrap only swaps a page whose Content-Security-Policy,
X-Frame-Options, Permissions-Policy, Referrer-Policy, and cross-origin
isolation headers are exactly the same as the current page's. Otherwise you
get a full page load, which applies that page's own headers.
The same happens for a page with a document-level <meta> (http-equiv such as a CSP or a
refresh, or name="referrer"), a page from a newer deployment, a redirect to a
page that cannot be swapped, and a response that is not an islands page, such
as a plain-text error or a file download.
The comparison fails closed. On a server running pracht, each page states which headers it was sent with, including any your middleware changed, and the browser checks them against the headers that arrived. On static output, the browser asks the host which headers it sends for the page you started on and swaps in only pages that arrive with the same, so it works on any static file host. A route that answers with different headers is fetched once, then loaded normally for the rest of the tab's session.
Pages whose CSP uses a nonce never take part: a nonce changes with every response, so no two such pages can share a policy. Islands navigation is off on those pages, and links to them are plain page loads.
It also stays off inside an iframe, on a document with a document-level
<meta>, under a Trusted Types policy that refuses HTML strings, in browsers
without the
Navigation API,
and on a static page the browser restored from its HTTP cache.
The option adds about 3.8 KB gzip to the bootstrap, and every islands page then
loads the bootstrap even when it renders no island: such a page goes from no
JavaScript to about 11 KB gzip. Each islands page also carries the route table
it decides with — the paths of your islands and none routes, plus API or
full-hydration routes that could shadow one — at about 150 bytes gzip for 20
routes. hydration: "none" pages still ship no JavaScript, so navigation
from a page you landed on directly is a full page load.
Build Analysis
pracht build --analyze counts islands routes differently from full hydration
routes:
"islands"routes include the islands bootstrap plus island chunks, with no shared client entry."none"routes report0bof client JavaScript.- Island chunks are reported as an upper bound, because which islands render is only known at render time.
This documentation page is itself wired with hydration: "islands" in the
example docs app. It renders no island components, so its generated HTML ships
no framework JavaScript.