~10KB gzipped · zero dependencies

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.

~10KB
Gzipped
0
Dependencies
No
Build Step
15+
Attributes
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) ~10KB ~14KB ~40KB+ ~15KB
Dependencies 0 0 Many 0
Learning curve HTML only HTML only JS + JSX + tooling HTML + JS