Concepts
Islands
Pages ship no JavaScript by default: add a client:* directive to a component and only that island hydrates in the browser.
#Concept
An island is a component instance that runs in the browser. You declare it at the usage site, so the same component can be static on one page and interactive on another:
<TeamPanel client:visible ids={ids} />On the server, the call compiles to an island record: the component's module and export name, its
props, its slots and the hydration strategy. The page HTML contains the server-rendered island
between comment markers (…) plus a JSON record; a small loader (about 1
kB) reads the records, arms the triggers and imports the island's module when one fires. Clicks,
input, submits and key presses that happen before hydration are replayed. An island whose module
fails to load or whose hydration throws is logged with its module, and those events get their
default actions (a link still navigates, a form still submits); a module that failed to load is
loaded again on the next interaction (details). Islands work in static
and server pages; how they compare with Astro's is in
Compared with Astro islands, and how a built page
gets per-reader parts through an island in
Close to partial prerendering.
Early events cover the island's own elements. A click, input, submit or key press whose target
is inside a server-rendered island that has not hydrated yet is held back, starts hydrating the
island at once (whatever its directive) and is dispatched again once it has, in the order the
events came: one that arrives after hydration but before the held ones are dispatched waits behind
them. Nothing else is replayed: a button outside the island that opens it (through a shared signal
or a custom event), a keyboard shortcut the island listens for on document or window, a scroll
or a pointer move find no listener before hydration, and are lost. For an island that such a trigger opens, a search
dialog opened from a header button or with Ctrl+K say, use client:load, or move the trigger inside
the island.
Directives (components only):
| Directive | Hydrates |
|---|---|
client:load |
Immediately. |
client:idle |
When the browser is idle (at the latest after 2 s). |
client:visible / client:visible="200px" |
When it enters the viewport (optional root margin). |
client:media="(max-width: 600px)" |
When the media query matches. |
client:only |
Client-rendered only: no server HTML. |
Props must be serializable: JSON values plus undefined, NaN/Infinity, BigInt, Date,
URL, Map, Set and FormData (its text entries; files are left out). Functions, symbols, class instances (a Response too), promises and cycles
are LUM4002.
Children and JSX props are slots. They are rendered on the server and stay server components:
they never ship their own JS. A slot the island rendered on the server is adopted in place when it
hydrates; one it did not render yet (behind a condition) is sent as <template data-l-slot="name">
and inserted when the island renders it. A slot renders in the page, before the island runs, so it
does not see the context the island provides around it. The parts of a compound component
(@lumiose/ui's Combobox.Item, Tabs.Tab, …) passed as a slot do not see the Root the
island renders, and the server throws "Combobox parts must be inside <Combobox.Root>". Pass the
data as a prop and render the parts inside the island:
// Throws: the items render in the page, outside the island's <Combobox.Root>.
<CityPicker client:load options={cities.map((c) => <Combobox.Item value={c}>{c}</Combobox.Item>)} />
// Works: the island renders the items from a prop.
<CityPicker client:load cities={cities} />What hydration does. The island's component runs again in the browser, with the props the
server serialized, and its compiled code claims the server's nodes instead of creating new ones:
the DOM, focus and text selections stay. Awaits replay the values recorded during SSR instead of
fetching again, while still tracking what they read, so a later change re-runs the component and
fetches. Everything outside islands stays server HTML that never hydrates. client:only renders on
the client without server HTML. An island inside a streamed <Suspense> waits for its content
before hydrating.
Nested directives. A client:* inside an island's own render is just a component: it is
already client-side.
Context starts at the island. An island hydrates as its own root, so context the page provided
outside it (a <Ctx value> provider in a layout or page) does not reach it, and the server renders it the same
way: useContext() inside an island returns the context's default, unless the island or a component
inside it provides the value. Contexts provided inside the island work on both sides. To hand page
data to an island, pass it as a prop; to share state between islands, use scoped state
(defineState, Sharing state): one instance per page, seeded on the server and sent
with the page. In dev, an island that reads a context the page provided warns
LUM4010. Slots are page content, so they keep seeing the page's context. <Suspense>,
<ErrorBoundary>, request APIs (locals(), cookies(), …) and getAbortSignal() work inside islands
during SSR as everywhere else.
URL state: searchParams. searchParams() from
lumiose/router is the page's query, reactive, and every
island of the page sees the same value. searchParams.set({ q }) loads the page with the new
query, so the server renders all of it again. To keep state in the query string without
reloading, pass { shallow: true }: set() then changes the URL with history.pushState()
({ replace: true } for replaceState()) and updates searchParams(), and back/forward between
those entries update it too. Nothing navigates then: the server-rendered parts of the page keep the
HTML of the URL they were rendered for, and the navigation hooks do not run. So the island owns
the query-dependent UI (a search box and its results table): the page passes the first results as
props, and the island fetches the next ones from an endpoint. A reload, or a shared link, renders
the new query on the server. During SSR, islands of server pages read the request's query; a
static page has none at build time (LUM5001), so its island reads it with client:only, or in
onMount(). params(), route() and url() work only inside spa routes: pass them as props.
Server-only boundaries. *.server.ts files and everything under src/server/ may not be
imported from code that reaches the browser (LUM4003): keep database access, secrets and
env() there. lumiose dev and lumiose build fail with the chain of importers when the client graph
reaches one, and lumiose check reports the import. Server-only APIs (request(), cookies(),
env(), …) throw LUM4003 in the browser build.
Awaited data inside an island is recorded during SSR and replayed during hydration, so the
browser does not fetch it again; it must be plain data (LUM4007), and it is visible in the page
source. Cached awaits (fetchJSON, query) are sent once per page however many islands await
them, and fill the browser cache, so later awaits and invalidate(url) start from them. So are the values of scoped state that islands import (defineState, LUM4011): the page
sends them once, before the islands that read them hydrate. The loader and hydrate() are described in lumiose/dom.
The loader script. lumiose dev and lumiose build add the loader to every page that renders an
island, once, as <script type="module" async>: in the <head> when the shell has islands,
otherwise with the first streamed chunk that does. In a build, pages without islands load no
JavaScript; the loader is the client entry, and each island is a chunk it imports when the island's
trigger fires. The loader's chunk also holds the Lumiose runtime the islands share (signals, DOM,
hydration), so a page loads one runtime file before its islands: about 8 to 10 kB min+brotli in
the sample apps (pnpm --filter lumiose size:apps reports them; pnpm --filter lumiose size checks
the 10 kB cap on what an islands page ships and the 15 kB cap on the whole client runtime). With your own server, pass its URL as
renderToStream(root, { loaderUrl }).
Code before hydration: src/instrumentation.client.ts. The place for what must run in the
browser before any island hydrates: an error-reporting or analytics SDK, a polyfill, global
listeners. The file is optional (.ts, .js or .mjs), and Lumiose loads it once per document
load on every page the app serves, as the first <script type="module"> of the head (in a build,
with <link rel="modulepreload"> for the chunks it imports):
- Before hydration. Its top-level code completes, top-level
awaitincluded, before any island hydrates and before the SPA router starts (prerendered SPA pages andssr = falseshells included). Meanwhile the loader already captures clicks, input and submits on islands, and replays them once the islands hydrated. Client navigations of an SPA do not run it again. - Pages without islands or SPA routes load it and its imports as their only scripts, without
the Lumiose runtime, also when it uses
import(). - Failures. What it throws is logged, and the page hydrates anyway.
- Keep it short.
lumiose devwarns when it takes longer than 16 ms (one frame) from the start of its module to the end of its top-level code;import()what is not needed at once. - Client code. It is bundled like an island: only
PUBLIC_*env reaches it, and importing server-only code isLUM4003. An edit reloads the page inlumiose dev. Withlumiose({ csp: "strict" })its tags carry the nonce like the loader's.
Errors and tracing: onError and instrumentations. Two optional exports of the file
receive what happens in the browser, as src/instrumentation.server.ts does on the server:
// src/instrumentation.client.ts
import type { ClientErrorInfo, ClientInstrumentation } from "lumiose";
export function onError(error: unknown, info: ClientErrorInfo) {
console.info("report", error, info.phase, info.route, info.island);
}
export const instrumentations: ClientInstrumentation[] = [
{
navigate: async (call, info) => {
const start = performance.now();
const result = await call();
console.info(`${info.to?.route}: ${result.status} in ${performance.now() - start} ms`);
},
hydrate: async (call, info) => {
performance.mark(`hydrate ${info.island}`);
await call();
},
},
];onError(error, info)receives errors of islands,"hydrate"(the island's module did not load, its hydration threw, or an event caught before it hydrated could not be dispatched again),"render"(an async component, or what an<ErrorBoundary>caught) and"effect"(an effect or binding after hydration), of the SPA router,"navigate"(a page that throws or whose module does not load), and ofsubmit(),"submit"(a rejection; not an abort).infois{ phase, route, island }: the SPA route (undefined on MPA pages) and the island'smodule#export. Errors keep reaching the console as before.instrumentationswrapnavigate({ type, from, to }, until the page is on screen),submit({ action, url }),fetch(eachfetchJSON()request,{ url }, orquery()function,{ key }) andhydrate(each island,{ island }), each with the currentroute. The rules are the server's: callcall()where the phase runs and await it to time it; it never throws and resolves to{ status: "success" }or{ status: "error", error }. A wrapper only observes, what it oronErrorthrows is logged, and the first observer is the outermost.- Cost. They are read before the first island hydrates, so its hydration is observed too.
Apps without the file ship none of this code:
lumiose buildturns the runtime's calls on only for apps that have it (about 0.7 kB brotli with islands, the router, actions andfetchJSON).
src/instrumentation.server.ts is its server counterpart (Server).
Types: lumiose.
Scripts of server-rendered components: ?client. A component that renders on the server and
needs a small script in the browser, without becoming an island (the definition of a custom
element it renders, an enhancement of native HTML, a theme toggle), imports the script with the
?client query and calls the import while it renders:
// src/components/copy-button.ts: runs in the browser only
customElements.define(
"copy-button",
class extends HTMLElement {
connectedCallback(): void {
// The server's data, from the attribute the component wrote.
const text = this.getAttribute("text") ?? "";
// On the element itself: clicks bubble from the button, which the parser may add later.
this.addEventListener("click", () => {
void navigator.clipboard.writeText(text);
});
}
},
);// src/components/CopyButton.stsx
import defineCopyButton from "./copy-button.ts?client";
// Types the attributes the server writes and the script reads.
declare module "lumiose/jsx-runtime" {
namespace JSX {
interface IntrinsicElements {
"copy-button": JSX.HTMLProps<HTMLElement> & { text: string };
}
}
}
export function CopyButton(props: { text: string }) {
defineCopyButton();
return (
<copy-button text={props.text}>
<button type="button">Copy</button>
</copy-button>
);
}- Only on the pages that render it. A page whose render calls the import gets one
<script type="module" async>for the script, with<link rel="modulepreload">for its imports, in the<head>, or with the streamed chunk of the<Suspense>boundary that rendered it. Without the island runtime: a page with no islands loads the script and nothing else. Pages that import the component without rendering it load nothing. - Never on the server. The script is not imported on the server, so it may use
customElements,documentandwindowat its top level. - Server data goes through attributes. The call takes no arguments. The component writes its
data as attributes of the element it renders, and the script reads them in
connectedCallback. Declare the element inJSX.IntrinsicElements, as above, so the server side is type-checked (Web components). Each instance carries its own data, and custom elements upgrade wherever they appear: streamed boundaries, SPA navigations, Markdown or CMS HTML. Attribute values are strings, so the script parses numbers, booleans and JSON itself. - It can run before the children exist. The script is async, so on a slow or streamed
response, or from the cache, it can define an element before the parser has added the
element's children:
connectedCallbackthen runs with the attributes but no children. Listen on the element itself, ascopy-buttondoes (events from later children bubble to it), and whiledocument.readyStateis"loading", wait forDOMContentLoadedbefore reading or replacing the children. - Elements the script creates are unscoped: the component's scoped styles do not reach them.
Render them on the server, or style them with
:deep()under an element of the scope (Web components). - Islands and SPA pages import the script with their own code, before the component runs, and
the call does nothing there: a page that gets it both ways runs it once. That takes a call that
is a statement of its own, at the top of a function's body as
defineCopyButton()above (or at the module's top level). A script that the module only calls conditionally, under anif, ina && f(),a ? f() : bor(f(), x), loads when the call runs, after the component, as on a server page: an island that never makes the call never loads it, soif (hover) hoverScript()costs nothing untilhoveris set. A call after an earlyreturnstill counts as its own statement. A custom element that an island passes properties to must be defined before the component runs: call its definition as a statement of its own (Web components). - Keep it free of the runtime. A script that imports
lumiosemakes its pages load the runtime. It is client code: onlyPUBLIC_*env reaches it, and importing server-only code isLUM4003. - Builds find
?clientimports in the project and its Lumiose packages, linked or installed from npm, and build each script as a chunk of its own. An import they do not find, such as one in a package that does not depend onlumioseor a module outside the project, is a build warning, and its pages do not load it: the first page that renders its component, while prerendering or on the server, warns which script it goes without. Inlumiose devthe script loads from its own URL. Its type comes fromlumiose/env: a default export() => void. .htmlimports load their<script>elements the same way (Markdown and HTML imports): a page that renders one, and no island, loads its scripts and no island runtime.
Build output. lumiose build puts the Lumiose runtime in one chunk: the island loader's, the SPA
entry's when the app has no islands, or a shared lumiose-[hash].js when it has both. The router's
History API fallback is a separate chunk, loaded only by browsers without the Navigation API.
Which runtime goes where follows one rule: the runtime chunk holds what the loader (or the SPA
entry) imports, plus the runtime that two or more islands (or SPA pages) import; runtime that only
one island imports goes into that island's chunk. A page's JavaScript then depends on its own
islands, not on every island of the app: the keyed selector the compiler emits for one island's
list, or submit() used by one form island, loads only on the pages that render that island. The trade-off: runtime shared
by several islands stays in the runtime chunk, so a page with none of them still loads it, but no
page loads more files than it has islands (a chunk per group of islands would cost one more request
each). Imports through lumiose and lumiose/dom count only the modules that declare what they
import. fetchJSON follows the same rule. One exception stays in the runtime chunk: a few bytes
that guard fetchJSON's cache, so the minifier can drop the cache in apps without async components
or resources, which it can only do within one chunk.
Dynamic imports go through a smaller preload helper than Vite's: it still adds the imported chunk's
stylesheets (and waits for them, skipping those the page already holds in its merged or inline
styles), module preloads and the CSP nonce of <meta property="csp-nonce">, but dispatches no vite:preloadError event (the router loads the
URL with a document navigation when a page's module does not load).
How island chunks load. In a build, an island's chunk imports other chunks: code it shares
with other islands (a component, a helper, a ?client script), and the runtime chunk. A browser
finds a chunk's imports only once the chunk itself has loaded, so each step down the import chain
would wait for the one before it. Lumiose starts the whole chain at once instead, and only for the
islands that are due:
client:loadandclient:onlyislands hydrate as soon as the loader runs. The page gets a<link rel="modulepreload">for the island's chunk and for every chunk it reaches through static imports, next to the loader's<script>(in the<head>for the shell, after the page's stylesheets, which block the first paint and so go first; in the streamed chunk for an island inside<Suspense>), so they all load in parallel with the loader. A chunk that several islands import is preloaded once, and so are the chunks the loader itself imports.client:idle,client:visibleandclient:mediaislands load nothing before their trigger fires. Then the loader adds module preloads for the island's chunk and every chunk it imports in one go (and links its stylesheets the page does not hold yet), and imports it.- A dynamic
import()inside an island loads when it runs, as usual.
In lumiose dev, modules are served one by one as Vite transforms them: an island that hydrates at
once gets a modulepreload for its own module only. With lumiose({ csp: "strict" }), the
preloads carry the response's nonce on pages rendered per request, and prerendered pages allow them
as 'self' (Security). spa pages preload their layouts and page the same way
(Rendering modes).
Islands from other packages. Components of a
Lumiose package, linked ("@acme/ui": "workspace:*") or
installed from npm, can be or render islands too. Island records name modules without
build-machine paths: root-relative inside the project
(/src/components/LikeButton.stsx#LikeButton), package-relative in a package
(@acme/ui/src/Nav.stsx#Nav, also for one installed under the project's node_modules), or by
the import's bare name (import { Nav } from "@acme/ui" gives @acme/ui#Nav). Either way, the
modulepreload hint of a client:load or client:only island points at its chunk in a build and at Vite's URL of
the module in dev. Builds also look for island call sites in the Lumiose packages, so each of
their islands gets its chunk even when only server code renders it. Folders inside a linked
package that are projects of their own (with a package.json, tsconfig.json or
vite.config.*, such as a showcase app) are left out.
Status: available: island calls and records (
LUM4001,LUM4002), the loader with its triggers and event replay, and hydration (LUM4006on a mismatch), inlumiose devand builds.
#API
| API | Use |
|---|---|
client:load, client:idle, client:visible, client:media, client:only |
Make a component instance an island. |
export function X |
Islands must be exported, so the browser can import them (LUM4001). |
*.server.ts, src/server/** |
Server-only modules. |
import.meta.env.PUBLIC_* |
Env values that may reach islands. |
src/instrumentation.client.ts |
Runs once per page load, before islands hydrate and the SPA starts. |
onError, instrumentations exports of it; ClientErrorInfo, ClientInstrumentation |
Report browser errors; observe hydration, navigations, submissions and requests. |
import x from "./script.ts?client", x() while rendering |
Load a browser script on the pages that render a component, without islands. |
#Example
// src/components/LikeButton.stsx: an island; exported so the browser can import it
import { signal } from "lumiose";
export function LikeButton({ initial, children }: { initial: number; children?: JSX.Element }) {
const likes = signal(initial);
return (
<button class="like" onClick={() => likes.update((n) => n + 1)}>
{children} {likes()}
</button>
);
}
<style>
.like { border-radius: 999px; }
</style>// src/routes/posts/[id]/page.stsx: a server component; only LikeButton ships JS
import { params } from "lumiose";
import { LikeButton } from "../../../components/LikeButton.stsx";
export default function Post() {
const { id } = params<"/posts/[id]">();
return (
<main>
<h1>Post {id}</h1>
<LikeButton client:visible initial={3}>
<strong>Like</strong>
</LikeButton>
</main>
);
}#Common mistakes
- A non-exported island.
function Counterused withclient:loadcannot be imported by the browser. Export it (LUM4001). - Passing functions or class instances as island props.
onSave={() => …}cannot be serialized. Pass data, and do the work inside the island, or post a form (LUM4002). - Importing server code into an island.
import { db } from "../server/db.server.ts"in an island's graph isLUM4003. Call an endpoint withfetchJSONinstead. - Touching
windowordocumentat module scope in a module that also runs on the server. Move it intoonMountor an effect (LUM4004). - Reading page context inside an island. An island hydrates as its own root, so it does not
see values the page provided outside it with a
<Ctx value>provider, and it renders the same way on the server:useContext()returns the context's default unless the island (or a component inside it) provides the value. Pass the value as a prop, or share scoped state (defineState, Sharing state); dev warns withLUM4010when the page did provide it. - Different output on server and client, for example from
Math.random()orDate.now()in render. Hydration reportsLUM4006in dev and client-renders the range in production. - Expecting
submit()to refresh the page. On MPA pages a successfulsubmit()does not reload: the island owns its state and updates itself from the result. Callinvalidate()when the page needs fresh server HTML (Data and actions). - Starting an SDK inside an island. It runs only where that island renders, and after other
islands may have hydrated. Start it in
src/instrumentation.client.ts, which every page runs first. - Making a whole page an island to get interactivity. Mark only the interactive parts, or use
an
sparoute (Choosing a mode). - Rendering query-dependent HTML on the server and changing the query shallowly.
searchParams.set(next, { shallow: true })does not re-render the page, so a table the server rendered fromurl().searchParamskeeps the old rows. Dropshallowto load the page, or render that table inside the island, from props and an endpoint.
#Related diagnostics
- LUM4001: island component is not exported.
- LUM4002: non-serializable island prop.
- LUM4003: server-only module in a client graph.
- LUM4004: browser API at module scope.
- LUM4006: hydration mismatch.
- LUM4007: non-serializable awaited value.
- LUM4010: island reads context provided by the page.
- LUM4011: non-serializable scoped state value.
See also: Rendering modes, Async components, Sharing state (state shared by islands).