~17KB gzipped · zero dependencies · up to 678x faster than React

xhtmlx

REST APIs meet declarative HTML

Like htmx, but your server returns JSON and xhtmlx renders it client-side. Build dynamic UIs with nothing but HTML attributes.
Up to 678x faster re-renders than Reactsee benchmarks.

~17KB
Gzipped
0
Dependencies
No
Build Step
678x
Faster Re-Renders ↓
index.html
Your HTML
<!-- That's it. No JavaScript. -->
<div xh-get="/api/users"
     xh-trigger="load"
     xh-target="#results">
  <template>
    <div xh-each="users">
      <p xh-text="name"></p>
    </div>
  </template>
</div>
<div id="results"></div>
Server returns JSON
{
  "users": [
    { "name": "Alice Chen" },
    { "name": "Bob Smith" },
    { "name": "Carol Diaz" }
  ]
}

↓ xhtmlx renders ↓

<div id="results">
  <p>Alice Chen</p>
  <p>Bob Smith</p>
  <p>Carol Diaz</p>
</div>

Declarative, from attribute to UI

Add HTML attributes to your elements. xhtmlx handles the fetch, template rendering, and DOM updates automatically.

htmx approach
1. Trigger event
2. Send request to server
3. Server renders HTML fragment
4. Swap HTML into DOM
xhtmlx approach
1. Trigger event
2. Send request to REST API
3. Server returns JSON data
4. Render template with data
5. Swap rendered result into DOM

Every HTTP method, one attribute

Your server stays a clean REST API returning JSON. xhtmlx handles every verb — from fetching data to creating, updating, and deleting resources.

</>
HTML
<div xh-get="/api/users" xh-trigger="load"> <template> <div xh-each="users"> <span xh-text="name"></span> <span xh-text="email"></span> </div> </template> </div>
Request / Response
GET /api/users
200 OK
{ "users": [ { "name": "Alice", "email": "alice@co.io" }, { "name": "Bob", "email": "bob@co.io" } ] }
Rendered UI
Result in the DOM
Alice alice@co.io
Bob bob@co.io
</>
HTML
<form xh-post="/api/users" xh-target="#list" xh-swap="beforeend"> <input name="name" value="Charlie"> <input name="email" value="charlie@co.io"> <button type="submit">Create</button> </form>
Request / Response
POST /api/users
// Request body (auto-serialized from form) { "name": "Charlie", "email": "charlie@co.io" }
201 Created
{ "id": 3, "name": "Charlie", "email": "charlie@co.io" }
Rendered UI
Appended to #list
Charlie charlie@co.io NEW
Form fields auto-serialized as JSON
Charlie charlie@co.io Create
</>
HTML
<button xh-put="/api/users/{{id}}" xh-vals='{"role":"admin"}' xh-target="#user-{{id}}" xh-swap="outerHTML" xh-template="/tpl/user.html"> Promote to Admin </button>
Request / Response
PUT /api/users/1
// Request body (from xh-vals) { "role": "admin" }
200 OK
{ "id": 1, "name": "Alice", "role": "admin" }
Rendered UI
Element replaced via outerHTML swap
Alice ADMIN Updated in-place
</>
HTML
<button xh-delete="/api/users/{{id}}" xh-target="closest .user-row" xh-swap="delete" xh-confirm="Remove this user?"> Remove </button>
Request / Response
DELETE /api/users/2
204 No Content
// No response body needed // xh-swap="delete" removes the target element
Rendered UI
Element removed from DOM
Alice alice@co.io
Bob bob@co.io
</>
HTML
<input type="checkbox" xh-patch="/api/todos/{{id}}" xh-vals='{"done":true}' xh-trigger="change" xh-target="#todo-{{id}}" xh-template="/tpl/todo.html">
Request / Response
PATCH /api/todos/5
// Only sends changed fields { "done": true }
200 OK
{ "id": 5, "title": "Ship v1", "done": true }
Rendered UI
Todo updated in-place
Ship v1 DONE

JSON becomes your data context

Every field in the JSON response is accessible via dot notation. Navigate parent chains with $parent, reach the top with $root, and track position with $index.

JSON Response
{
  "user": {
    "name": "Alice Chen",
    "role": "admin",
    "team": {
      "name": "Engineering",
      "members": [
        { "name": "Bob" },
        { "name": "Carol" }
      ]
    }
  }
}
Template Access
<!-- Dot notation for nested fields -->
<h1 xh-text="user.name"></h1>
<!-- Renders: Alice Chen -->

<span xh-text="user.team.name"></span>
<!-- Renders: Engineering -->

<!-- Inside xh-each, $parent accesses outer scope -->
<div xh-each="user.team.members">
  <span xh-text="name"></span>
  <!-- $index: 0, 1, ... -->
  <span xh-text="$parent.user.role"></span>
  <!-- Renders: admin -->
  <span xh-text="$root.user.name"></span>
  <!-- Renders: Alice Chen -->
</div>
Root Context
$root · full JSON
Child Context
$parent · dot path
Each Item Context
$index · current item

Compose UIs from reusable template files

Three ways to define templates: load from a file, embed inline, or bind directly to the element. All support xhtmlx attributes for recursive composition.

External file
Load a reusable template from a separate HTML file. Cached after first fetch.
index.html
<div xh-get="/api/user/1"
     xh-trigger="load"
     xh-template="/templates/user.html">
</div>
/templates/user.html
<div class="card">
  <h2 xh-text="name"></h2>
  <p xh-text="email"></p>
</div>
Inline template
Embed a <template> tag inside the element. No extra file needed.
index.html
<div xh-get="/api/user/1"
     xh-trigger="load">
  <template>
    <div class="card">
      <h2 xh-text="name"></h2>
      <p xh-text="email"></p>
    </div>
  </template>
</div>
Self-binding
The element itself is the template. Use xh-text, xh-attr-* directly on it or its children.
index.html
<span xh-get="/api/count"
      xh-trigger="load"
      xh-text="total">
</span>

<img xh-get="/api/user/1"
     xh-trigger="load"
     xh-attr-src="avatar"
     xh-attr-alt="name">
Templates are cached after first load
🔁 Templates can contain xhtmlx attributes (recursive)
{{}} Supports {{field}} URL interpolation

Everything you need, nothing you don't

Powerful enough for production apps. Simple enough to learn in an afternoon.

</>
REST Verbs
Full HTTP method support for CRUD operations. GET, POST, PUT, DELETE, and PATCH — all from HTML attributes.
xh-get xh-post xh-put xh-delete xh-patch
📄
External Templates
Load templates from separate files with automatic caching. Multiple elements can share the same template without extra network requests.
xh-template="/tpl/card.html"
🔗
Data Binding
Bind JSON fields to text content, raw HTML, or any element attribute. Supports dot notation for nested objects.
xh-text xh-html xh-attr-*
🔁
Iteration
Loop over arrays with access to $index, $parent, and $root context variables. Renders performantly with batching for large lists.
xh-each="items"
Conditionals
Show or hide elements based on data values. Render admin badges, verified status, empty states, and more.
xh-if xh-unless
Smart Triggers
Trigger requests on click, page load, scroll reveal, polling intervals, or custom events. Add debounce, throttle, and once modifiers.
xh-trigger="keyup delay:300ms"
🎯
Flexible Targeting
Control where rendered content goes with CSS selectors and 8 swap modes: innerHTML, outerHTML, append, prepend, and more.
xh-target xh-swap
🛡
3-Level Error Handling
Per-element error templates, error boundaries that catch child errors, and a global fallback. Status-specific templates for 404, 4xx, 5xx.
xh-error-template xh-error-boundary
Loading Indicators
Built-in indicator support with CSS-driven show/hide. No flickering — smooth opacity transitions come out of the box.
xh-indicator="#spinner"
{{}}
URL Interpolation
Embed data values in URLs with double-curly syntax. Supports dot notation and automatic URI encoding.
xh-get="/api/users/{{id}}"
📣
Custom DOM Events
Lifecycle hooks for every stage: beforeRequest, afterRequest, beforeSwap, afterSwap, and responseError. Cancel requests programmatically.
xh:beforeRequest xh:afterSwap
💫
Zero Dependencies
A single file at approximately 10KB gzipped. No build step, no framework, no toolchain. Just add a script tag and go.
<script src="xhtmlx.js">

Eight ways to update the DOM

Control exactly where rendered content lands with xh-swap. Combine with xh-target to direct output to any element on the page.

innerHTML
<div id="target">
  new content
</div>
xh-swap="innerHTML"
outerHTML
<div id="target">...</div>
new content replaces element
xh-swap="outerHTML"
beforeend
<div id="target">
  existing content
  new content ←
</div>
xh-swap="beforeend"
afterbegin
<div id="target">
  → new content
  existing content
</div>
xh-swap="afterbegin"
beforebegin
new content ←
<div id="target">
  existing content
</div>
xh-swap="beforebegin"
afterend
<div id="target">
  existing content
</div>
new content ←
xh-swap="afterend"
delete
<div id="target">
  content
</div>
removed from DOM
xh-swap="delete"
none
<div id="target">
  unchanged
</div>
events still fire
xh-swap="none"

Use xh-target="#other-element" to direct rendered output to a different element on the page.

See it in action

Real-world patterns you can copy-paste. Every line is plain HTML — no JavaScript required.

Load and render data with a single element
On page load, fetch JSON from the API and render each user with the inline template.
index.html
<div xh-get="/api/users"
     xh-trigger="load"
     xh-target="#user-list">
  <template>
    <div xh-each="users">
      <div class="user-card">
        <img xh-attr-src="avatar" xh-attr-alt="name">
        <h3 xh-text="name"></h3>
        <p xh-text="email"></p>
        <span xh-if="is_admin" class="badge">Admin</span>
      </div>
    </div>
  </template>
  <span class="xh-indicator">Loading...</span>
</div>

<div id="user-list"></div>
JSON Response from /api/users
{
  "users": [
    { "name": "Alice", "email": "alice@co.io", "avatar": "/img/alice.jpg", "is_admin": true },
    { "name": "Bob", "email": "bob@co.io", "avatar": "/img/bob.jpg", "is_admin": false }
  ]
}
Full CRUD with forms and REST verbs
Create, read, update, and delete records using nothing but HTML attributes and standard forms.
Create + List
<!-- Create a new user -->
<form xh-post="/api/users"
      xh-target="#user-list"
      xh-swap="beforeend"
      xh-template="/templates/user-row.html">
  <input name="name" placeholder="Name" required>
  <input name="email" type="email" placeholder="Email">
  <button type="submit">Add User</button>
</form>

<!-- List existing users -->
<div xh-get="/api/users" xh-trigger="load"
     xh-template="/templates/user-table.html"
     xh-target="#user-list"></div>
<div id="user-list"></div>
/templates/user-row.html (Update + Delete)
<tr id="user-{{id}}">
  <td xh-text="name"></td>
  <td xh-text="email"></td>
  <td>
    <button xh-put="/api/users/{{id}}"
            xh-vals='{"role": "admin"}'
            xh-target="#user-{{id}}"
            xh-swap="outerHTML"
            xh-template="/templates/user-row.html">Promote</button>

    <button xh-delete="/api/users/{{id}}"
            xh-target="#user-{{id}}"
            xh-swap="delete">Remove</button>
  </td>
</tr>
Nested templates with cascading API calls
Templates can trigger additional API calls, creating a composition tree. Child templates access parent data via $parent.
index.html
<div xh-get="/api/users/1"
     xh-trigger="load"
     xh-template="/templates/user-profile.html"></div>
/templates/user-profile.html
<div class="profile">
  <h1 xh-text="name"></h1>
  <p xh-text="bio"></p>

  <!-- Nested: load this user's posts -->
  <div xh-get="/api/users/{{id}}/posts"
       xh-trigger="load">
    <template>
      <div xh-each="posts">
        <h3 xh-text="title"></h3>
        <small>by <span xh-text="$parent.name"></span></small>
      </div>
    </template>
  </div>
</div>
Live search with debounce
Trigger a search API call on every keystroke, debounced by 300ms. Only fires when the value actually changes.
search.html
<input type="search"
       name="q"
       placeholder="Search users..."
       xh-get="/api/search"
       xh-trigger="keyup changed delay:300ms"
       xh-target="#search-results"
       xh-indicator="#search-spinner">

<span id="search-spinner" class="xh-indicator">Searching...</span>

<div id="search-results">
  <!-- Results appear here -->
</div>

<!-- Auto-refresh every 30s -->
<div xh-get="/api/stats"
     xh-trigger="every 30s">
  <template>
    <span>Online: <b xh-text="count"></b></span>
  </template>
</div>
Granular error handling with boundaries
Handle specific HTTP status codes differently. Use error boundaries to catch errors from any child widget.
dashboard.html
<!-- Error boundary catches unhandled errors from children -->
<div xh-error-boundary
     xh-error-template="/templates/error.html"
     xh-error-target="#dashboard-errors">
  <div id="dashboard-errors"></div>

  <!-- Widget with status-specific error templates -->
  <div xh-get="/api/profile"
       xh-trigger="load"
       xh-template="/templates/profile.html"
       xh-error-template-404="/templates/not-found.html"
       xh-error-template-4xx="/templates/client-error.html"
       xh-error-template="/templates/error.html"></div>

  <!-- This widget has no error template -->
  <!-- If it fails, the boundary catches it -->
  <div xh-get="/api/stats" xh-trigger="load">
    <template><span xh-text="total"></span></template>
  </div>
</div>
/templates/error.html
<div class="error-box">
  <h3>Error <span xh-text="status"></span><span xh-text="statusText"></span></h3>
  <p xh-text="body.message"></p>
  <ul xh-if="body.fields">
    <li xh-each="body.fields">
      <strong xh-text="name"></strong>: <span xh-text="message"></span>
    </li>
  </ul>
</div>

Real API calls, right here

These widgets use the actual xhtmlx library to make real HTTP requests to JSONPlaceholder. Open DevTools Network tab to see the requests fly.

Open Playground — write your own xhtmlx code with a built-in mock API

Load Users GET
Loading...
View source HTML
<button xh-get="https://jsonplaceholder.typicode.com/users?_limit=4"
        xh-target="#demo-users"
        xh-indicator="#loading">
  Fetch Users
</button>
<div id="demo-users">
  <template>
    <div xh-each="$self">
      <div class="user-card">
        <span xh-text="name"></span>
        <span xh-text="email"></span>
        <span xh-text="company.name"></span>
      </div>
    </div>
  </template>
</div>
Load Posts (User 1) GET
Loading...
View source HTML
<button xh-get=".../posts?userId=1&_limit=3"
        xh-target="#demo-posts">
  Fetch Posts
</button>
<div id="demo-posts">
  <template>
    <div xh-each="$self">
      <span xh-text="title"></span>
      <span xh-text="body"></span>
    </div>
  </template>
</div>
Create Post POST
Create Post
Sending...
View source HTML
<div xh-post=".../posts"
     xh-vals='{"title":"Hello!","body":"..."}'
     xh-target="#result"
     xh-trigger="click">
  Create Post
</div>
<div id="result">
  <template>
    <span xh-text="id"></span>
    <span xh-text="title"></span>
  </template>
</div>
Todos (auto-load) GET
Loading todos...
View source HTML
<div xh-get=".../todos?_limit=5"
     xh-trigger="load"
     xh-target="#todos">
</div>
<div id="todos">
  <template>
    <div xh-each="$self">
      <span xh-if="completed">✓</span>
      <span xh-unless="completed">○</span>
      <span xh-text="title"></span>
    </div>
  </template>
</div>
Network Log

Add one line. Start building.

No build tools, no configuration, no package managers required. But they work too.

</> CDN / Script Tag Easiest
<!-- Add to your HTML -->
<script src="https://unpkg.com/xhtmlx"></script>

<!-- Or pin a specific version -->
<script src="https://unpkg.com/xhtmlx@1.0.0"></script>
npm Package manager
# Install
npm install xhtmlx

# Then add to your HTML
<script src="node_modules/xhtmlx/xhtmlx.js"></script>

How xhtmlx compares

Different tools make different tradeoffs. Here is where xhtmlx sits in the landscape.

Feature xhtmlx htmx React Alpine.js
Server returns JSON HTML JSON N/A
Build step required No No Yes No
Client-side rendering Yes No Yes Yes
External template files Yes N/A JSX No
Declarative HTML Yes Yes No Yes
REST API friendly Yes No Yes Yes
File size (gzipped) ~17KB ~14KB ~46KB+ ~15KB
Dependencies 0 0 Many 0
Learning curve HTML only HTML only JS + JSX + tooling HTML + JS

Up to 678x faster re-renders than React

xhtmlx patches only changed DOM bindings in place. No virtual DOM, no diffing, no reconciliation. When data changes, only the pixels that need to change, change.

678x
Faster re-renders
vs React 19 (noop patching)
~17KB
Gzipped — 2.7x smaller
than React + ReactDOM
20,493x
Faster render + swap
(1,000-item list vs React)

Re-Render / Patching — data change to DOM update

Scenario xhtmlx React 19 Result
1 text — changing data 13.48M ops/s 49.7K ops/s xhtmlx 271x
1 text — same data (noop) 12.81M ops/s 32.0K ops/s xhtmlx 400x
5 text — same data (noop) 32.48M ops/s 128.1K ops/s xhtmlx 253x
10 text — changing data 1.45M ops/s 8.1K ops/s xhtmlx 178x
Card component — changing 5.98M ops/s 25.2K ops/s xhtmlx 237x
Card component — same (noop) 20.67M ops/s 30.5K ops/s xhtmlx 678x
Conditional — same (noop) 16.11M ops/s 44.9K ops/s xhtmlx 359x
Profile widget — same (noop) 14.82M ops/s 22.9K ops/s xhtmlx 647x

xhtmlx's render() API patches only changed bindings in place — no virtual DOM diff, no reconciliation, no tree walk. This is the real-world advantage for polling, WebSocket streams, and reactive state updates.

Render + Swap — renderTemplate + performSwap pipeline

Scenario xhtmlx React 19 Result
Single text binding 1.41M ops/s 25.4K ops/s xhtmlx 56x
5 text bindings 1.47M ops/s 55.7K ops/s xhtmlx 26x
Conditional render 1.52M ops/s 17.0K ops/s xhtmlx 89x
User profile card 1.37M ops/s 8.0K ops/s xhtmlx 171x
List — 100 items 1.50M ops/s 815 ops/s xhtmlx 1,836x
List — 500 items 1.76M ops/s 209 ops/s xhtmlx 8,405x
List — 1,000 items 1.94M ops/s 95 ops/s xhtmlx 20,493x

performSwap auto-patches when the same template is re-rendered into the same target — the DOM is never rebuilt, only changed bindings are updated. Combined with lazy fragment construction, the full pipeline matches render() speed.

Benchmarks run in JSDOM with Jest. React uses flushSync for fair synchronous comparison.
Source: tests/benchmark/ · 3 runs averaged per measurement · React 19.2 · xhtmlx 0.4.1