Interactive islands
For what genuinely needs a real framework — a dashboard, a complex cart — without the rest of the site stopping being HTML-first.
Is this the same as Astro's islands? Not quite.
In Astro, an island is a real React/Vue/Svelte component, and Astro's own renderer for that framework executes that component on the server to produce the island's initial HTML. Nexa has no equivalent step: the Rust compiler never opens the file that data-nexa-island points to, never executes it, never understands it. To Rust, an island is literally just another HTML tag with a text attribute — the same treatment any other div gets.
That's why the content you see inside the div before JS loads (the "fallback") wasn't generated by any framework: you wrote it yourself, with the same static tools any Nexa page uses (data.*, t(), <For>). And that's why, once the island's JS does load, it runs 100% in the browser — never on the server. This same idea (compiler as a pattern analyzer, never as an executor of your code) is also why a normal Nexa page, without any islands, never needs to "hydrate" either: every button activates on its own, with its own tiny piece of JS — there's no whole component tree to revive.
Marking the island
<div data-nexa-island="dashboard" data-nexa-strategy="visible">
<p>Loading panel…</p>
</div>The server only renders the fallback (the div's children, real content); mounting happens 100% on the client.
The mount contract
// src/islands/dashboard.island.ts
export default function mount(el, props) {
// hand-written @nexa/reactivity, or a Vue/Preact/etc. adapter
return () => { /* optional cleanup */ };
}Any framework can implement this: hand-written code with @nexa/reactivity, or an adapter over Vue, Preact, whatever.
Without reopening composition
// nexa.toml
[imports]
dashboard = "/vendor/dashboard-island.js"Rust never opens or interprets the specifier's module — to the compiler, a data-nexa-island is just another Element, like any other attribute.
The middle ground: a lightweight component model
import { mountComponent, type Component } from "@nexa/reactivity";
interface ItemProps { label: string }
const Item: Component<ItemProps> = (el, props) => {
el.textContent = props.label;
};
interface ListProps { labels: string[] }
const List: Component<ListProps> = (el, props, ctx) => {
for (const label of props.labels) {
const li = document.createElement("li");
el.appendChild(li);
ctx.mount(Item, li, { label });
}
};
export default function mount(el: Element, props: Record<string, unknown>) {
return mountComponent(List, el, { labels: props.labels as string[] });
}When an island needs more than a loose signal but doesn't justify loading a full external framework (a dropdown, a simple chat widget), @nexa/reactivity offers real composition — Component<P> typed by props, mountComponent() returning a cleanup function, and a ComponentContext (onCleanup/mount) that automatically chains a child's cleanup to its parent's. Still zero Virtual DOM, and exclusively for code inside a native island — never for Nexa Core pages. The same task-list widget that used to need full Vue 3 (~245KB) weighs ~4KB with this.
Using a real npm library inside an island
// src/islands/markdownPreview.island.ts
import { marked } from "marked"; // normal import, bundled by esbuild
export default function mount(el: Element, props: Record<string, unknown>) {
const textarea = document.createElement("textarea");
textarea.value = typeof props.initial === "string" ? props.initial : "";
const preview = document.createElement("div");
const render = () => { preview.innerHTML = marked.parse(textarea.value) as string; };
textarea.addEventListener("input", render);
el.append(textarea, preview);
render();
}
// src/pages/index.tsx
<div
data-nexa-island="markdownPreview"
data-nexa-strategy="load"
data-nexa-props={{ initial: "# Hello\n\nThis is real *Markdown*." }}
>
<p>Loading editor…</p>
</div>
// nexa.toml — this [imports] locates the already-bundled island FILE,
// not the marked library (that's already inside the bundle)
[imports]
markdownPreview = "/vendor/markdown-preview-island.js"Note this is different from the [imports] mechanism you saw for handlers: an island file is bundled in full with esbuild (--bundle --format=esm), so a normal import at the top of the file resolves exactly like in any TypeScript project — nothing to declare in nexa.toml for the library itself. This example (tested end-to-end in a real browser) uses real marked to convert Markdown to HTML live, as the user types.