---
title: JSX-less
description: Use Ilha with html template tags instead of JSX — no JSX runtime, no compiler, no build transform.
---

You can author islands entirely with [`html`](/guide/recipes/helpers#build-html-strings) template tags instead of JSX. This is the core reactive API with zero JSX tooling: no `jsx` setting, no JSX runtime import, no Babel or SWC transform, and no tsconfig tweak. You write ordinary `.js` or `.ts` files and run them anywhere — Node, Bun, Deno, or the browser.

```js
import { ilha, html, state } from "ilha";

export const Counter = ilha(() => {
  const count = state(0);

  return html`
    <button onclick=${() => count((value) => value + 1)}>
      count: ${count()}
    </button>
  `;
});
```

`html` is a plain tagged template. It returns a `RawHtml` object and applies Ilha's escaping and composition rules, so it is not just a wrapper around a string — the same event, binding, and interpolation semantics you know from JSX live in the template.

## Primitives work the same

`state()`, `derived()`, `action()`, `effect()`, and `onError()` behave identically in a JSX-less island. Only the markup syntax changes. The signal-accessor shorthand also works: interpolate `count` directly and Ilha calls it for you.

```js
import { derived, ilha, html, state } from "ilha";

export const Totals = ilha(() => {
  const count = state(0);
  const doubled = derived(() => count() * 2);

  return html`
    <p>${count} doubled is ${doubled()}</p>
    <button onclick=${() => count((value) => value + 1)}>
      +1
    </button>
  `;
});
```

## Events and bindings

Lowercase native event props take a function interpolation. Ilha attaches the listener with `addEventListener` on mount, never serializing the function into the emitted HTML.

```js
import { ilha, html, state } from "ilha";

export const NameForm = ilha(() => {
  const name = state("");

  return html`
    <form onsubmit=${(event) => event.preventDefault()}>
      <input bind:value=${name} />
      <p>Hello, ${name}.</p>
    </form>
  `;
});
```

`bind:value`, `bind:checked`, `bind:group`, `bind:this`, and the other [`bind:*`](/guide/island/bindings) forms are all available, as are the event modifiers `:once`, `:capture`, `:passive`, and `:abortable`.

## Conditionals and lists

A `null` or `undefined` interpolation renders as nothing, so conditionals are just expressions:

```js
import { ilha, html, state } from "ilha";

export const Notice = ilha(() => {
  const error = state(null);

  return html`
    <div>
      ${error() ? html`<p role="alert">${error()}</p>` : ""}
    </div>
  `;
});
```

Render a list by mapping each item to an `html` result. Array items are processed recursively with no comma joining, and `html` results pass through unescaped.

```js
import { ilha, html, state } from "ilha";

export const Fruits = ilha(() => {
  const fruits = state(["apple", "banana", "cherry"]);

  return html`
    <ul>
      ${fruits().map((fruit) => html`<li>${fruit}</li>`)}
    </ul>
  `;
});
```

## Composition

Reuse markup by splitting it into plain functions that return an `html` result and calling them where you need them:

```js
import { html } from "ilha";

const Badge = (label) =>
  html`<span class="badge">${label}</span>`;

export const Card = ilha(
  () => html` <article>${Badge("New")}</article> `,
);
```

A nested island is interpolated as `Island(props)` (or as `Island` when it needs no props) to create an independent island boundary:

```js
import { ilha, html, state } from "ilha";

const Counter = ilha(() => {
  const count = state(0);
  return html`<button onclick=${() => count((v) => v + 1)}>
    ${count}
  </button>`;
});

export const App = ilha(
  () => html` <section>${Counter()} ${Counter()}</section> `,
);
```

Use `Counter.key("stable")(props)` for keyed identity. Interpolating a plain string that contains markup requires `raw()` (or an `html` result) so it passes through safely.

```js
import { html, raw } from "ilha";

const icon = `<svg aria-hidden="true">…</svg>`;

export const Dropdown = ilha(
  () => html` <button>Options ${raw(icon)}</button> `,
);
```

## Server rendering and hydration

The same island renders or hydrates with the same APIs you already use:

```js
import { ilha, html, state } from "ilha";

const Counter = ilha(() => {
  const count = state(0);
  return html`<button onclick=${() => count((v) => v + 1)}>
    ${count}
  </button>`;
});

// SSR
Counter.toString();
await Counter.toStringAsync();

// Emit hydration markup, then mount on the client
await Counter.hydratable(props, {
  name: "Counter",
  snapshot: true,
});
```

The only difference from the JSX path is the markup syntax. Interpolation escaping, URL/scheme filtering, script and style data via [`json()`](/guide/recipes/helpers#script-and-style-data) and `css()`, and the rest of the `html` rules apply unchanged.

## When to pick JSX over `html`

Reach for `html` when you want no build step — server-only rendering, a single `<script>` tag, a small script, or a project that does not already transform TSX. Reach for JSX when you prefer element syntax and type-aware JSX props, or when most of your codebase is already TSX. There is no runtime gap between the two; authoring style is the only difference.

For more on `html` escaping, lists, whitespace handling, and the full interpolation table, see [Helpers](/guide/recipes/helpers#build-html-strings).
