Nexanexa
Step 9

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.