#
Foundations: Hypermedia + htmx Mental Model
Goal: Establish the "server-driven UI" mindset and give attendees just enough htmx vocabulary to be productive in labs.
#
1. What "Hypermedia" Means in Web Apps
#
The Original Web Architecture
The web was born as a hypermedia system. Tim Berners-Lee didn't build a "JSON API transport layer"—he built a document network where the server sends self-describing, interactive content.
Hypermedia = data + controls (links and forms) bundled together in a single response.
<!-- This IS hypermedia: data AND controls together -->
<article>
<h1>Order #1234</h1>
<p>Status: Pending</p>
<!-- The server tells the client what actions are possible -->
<a href="/orders/1234/cancel">Cancel Order</a>
<form action="/orders/1234/ship" method="post">
<button>Ship Now</button>
</form>
</article>
#
HTML as the Contract
In a hypermedia architecture, HTML is the API contract. The server doesn't return abstract data that the client must interpret—it returns ready-to-render UI with embedded affordances.
#
State Transitions via Links and Forms
In REST's original formulation (Fielding's dissertation), hypermedia controls drive application state. This is HATEOAS—Hypermedia As The Engine Of Application State.
Think of it like a choose-your-own-adventure book:
- You're on page 47 (current state)
- The page shows you options: "Turn to page 62" or "Turn to page 78"
- You don't need to know the whole book's structure—just follow the links
┌─────────────────┐ GET /cart ┌─────────────────┐
│ Product Page │ ─────────────────► │ Cart Page │
│ │ │ │
│ [Add to Cart] │ │ [Checkout] │
└─────────────────┘ │ [Continue] │
└─────────────────┘
│
POST /checkout
│
▼
┌─────────────────┐
│ Checkout Page │
│ │
│ [Place Order] │
│ [Back to Cart] │
└─────────────────┘
Key insight: The server controls the workflow. The client just follows links.
#
The SPA Deviation
Single-Page Applications (SPAs) broke from this model:
Traditional Web (Hypermedia) SPA Architecture
──────────────────────────── ─────────────────
Browser ◄──── HTML ──── Server Browser ◄──── JSON ──── Server
│
[Fat JS Client]
│
Interprets data,
builds UI,
manages state
SPAs moved the "application" to the browser. The server became a dumb data pipe. This created:
- Duplicated business logic (server validates, client validates)
- Complex client-side state management
- Hundreds of kilobytes of JavaScript
- SEO and accessibility challenges
#
2. Why htmx Exists
#
The Problem htmx Solves
Modern web development often looks like this:
// 1. Fetch JSON data
const response = await fetch('/api/users/123');
const user = await response.json();
// 2. Transform data to UI
const html = `
<div class="user-card">
<h2>${escapeHtml(user.name)}</h2>
<p>${escapeHtml(user.email)}</p>
${user.isAdmin ? '<span class="badge">Admin</span>' : ''}
</div>
`;
// 3. Find the right place in the DOM
const container = document.getElementById('user-container');
// 4. Swap it in
container.innerHTML = html;
// 5. Re-attach event listeners (the ones you just destroyed)
container.querySelector('.edit-btn')?.addEventListener('click', handleEdit);
htmx asks: What if the browser could do this natively for any element, not just <a> and <form>?
#
HTML Over the Wire
htmx extends HTML so any element can make HTTP requests and swap the response into the DOM:
<!-- Before: Requires JavaScript -->
<button onclick="loadUsers()">Load Users</button>
<!-- After: Pure HTML + htmx -->
<button hx-get="/users" hx-target="#user-list">
Load Users
</button>
The server returns HTML fragments, not JSON:
<!-- Server response (a partial, not a full page) -->
<ul id="user-list">
<li>Alice</li>
<li>Bob</li>
</ul>
#
Progressive Enhancement
htmx builds on progressive enhancement—the idea that your app should work without JavaScript, then get better with it.
<!-- Works without JavaScript (full page reload) -->
<form action="/search" method="get">
<input name="q" type="text">
<button>Search</button>
</form>
<!-- Enhanced with htmx (inline update) -->
<form action="/search" method="get"
hx-get="/search"
hx-target="#results"
hx-trigger="submit, input changed delay:300ms">
<input name="q" type="text">
<button>Search</button>
</form>
Both versions work. The htmx version is just smoother.
#
Less JavaScript Surface Area
htmx's philosophy: The server already knows how to build HTML. Let it.
#
3. Core htmx Concepts
#
3.1 Requests: hx-get / hx-post / hx-put / hx-patch / hx-delete
These attributes issue AJAX requests when triggered:
<!-- GET request -->
<button hx-get="/api/status">Check Status</button>
<!-- POST request -->
<button hx-post="/api/orders" hx-vals='{"item": "widget"}'>
Place Order
</button>
<!-- DELETE request -->
<button hx-delete="/api/orders/123">Cancel Order</button>
Default behavior:
- Triggered by:
click(for most elements),submit(for forms),change(for inputs) - Response swapped into: the element that made the request
- Swap strategy:
innerHTML
#
3.2 Targeting: hx-target
By default, htmx swaps the response into the element that triggered the request. Use hx-target to swap somewhere else:
<!-- Swap into a different element -->
<button hx-get="/users" hx-target="#user-list">
Load Users
</button>
<div id="user-list"><!-- Users appear here --></div>
<!-- Target selectors -->
<button hx-target="#specific-id">By ID</button>
<button hx-target=".some-class">By class (first match)</button>
<button hx-target="closest div">Closest ancestor</button>
<button hx-target="find .child">First matching descendant</button>
<button hx-target="next .sibling">Next sibling matching</button>
<button hx-target="previous .sibling">Previous sibling matching</button>
<button hx-target="this">The triggering element itself</button>
#
3.3 Swapping: hx-swap
Controls how the response content replaces target content:
<!-- innerHTML (default): Replace target's children -->
<div hx-get="/content" hx-swap="innerHTML">
<!-- new content goes INSIDE here -->
</div>
<!-- outerHTML: Replace the entire target element -->
<div hx-get="/content" hx-swap="outerHTML">
<!-- this entire div gets replaced -->
</div>
<!-- beforebegin: Insert before the target -->
<ul>
<li hx-get="/new-item" hx-swap="beforebegin">
<!-- new content appears ABOVE this li -->
</li>
</ul>
<!-- afterend: Insert after the target -->
<li hx-get="/new-item" hx-swap="afterend">
<!-- new content appears BELOW this li -->
</li>
<!-- beforeend: Append inside target (great for infinite scroll) -->
<ul hx-get="/more-items" hx-swap="beforeend">
<li>Item 1</li>
<li>Item 2</li>
<!-- new items append here -->
</ul>
<!-- afterbegin: Prepend inside target -->
<ul hx-get="/latest" hx-swap="afterbegin">
<!-- new items appear first -->
<li>Old Item</li>
</ul>
<!-- delete: Remove the target (no response body needed) -->
<button hx-delete="/items/1" hx-target="closest li" hx-swap="delete">
Remove
</button>
<!-- none: Make request but don't swap anything -->
<button hx-post="/track-click" hx-swap="none">
Track Me
</button>
Swap modifiers (combine with any strategy):
<!-- Add transition timing -->
<div hx-get="/content" hx-swap="innerHTML swap:500ms">
<!-- Waits 500ms before swapping -->
</div>
<!-- Settle time for CSS transitions -->
<div hx-get="/content" hx-swap="innerHTML settle:300ms">
<!-- Allows 300ms for CSS transitions after swap -->
</div>
<!-- Scroll behavior -->
<div hx-get="/content" hx-swap="innerHTML scroll:top">
<!-- Scrolls to top of target after swap -->
</div>
<!-- Focus behavior -->
<div hx-get="/form" hx-swap="innerHTML focus-scroll:true">
<!-- Scrolls to focused element -->
</div>
#
3.4 Triggers: hx-trigger
Controls when requests fire:
<!-- Standard events -->
<button hx-get="/data" hx-trigger="click">Click me</button>
<input hx-get="/search" hx-trigger="keyup">
<form hx-post="/submit" hx-trigger="submit">
<!-- Multiple triggers -->
<input hx-get="/search" hx-trigger="keyup, change">
<!-- Modifiers -->
<input hx-get="/search" hx-trigger="keyup changed delay:500ms">
<!--
- changed: only if value changed
- delay:500ms: debounce (wait 500ms after last event)
-->
<!-- Throttle (max once per interval) -->
<div hx-get="/status" hx-trigger="every 2s">
<!-- Polls every 2 seconds -->
</div>
<!-- Trigger once -->
<img hx-get="/load-image" hx-trigger="revealed once">
<!--
- revealed: when element scrolls into viewport
- once: only trigger one time
-->
<!-- From another element -->
<input id="search" name="q">
<div hx-get="/results" hx-trigger="keyup from:#search delay:300ms">
<!-- Triggered by keyup on the input -->
</div>
<!-- Load trigger (fires on page load) -->
<div hx-get="/initial-data" hx-trigger="load">
Loading...
</div>
<!-- Intersection observer -->
<div hx-get="/more" hx-trigger="intersect threshold:0.5">
<!-- Fires when 50% visible -->
</div>
Common trigger patterns:
<!-- Live search -->
<input type="search" name="q"
hx-get="/search"
hx-trigger="input changed delay:300ms"
hx-target="#results">
<!-- Infinite scroll -->
<div hx-get="/page/2"
hx-trigger="revealed"
hx-swap="afterend">
Loading more...
</div>
<!-- Auto-save -->
<form hx-post="/autosave"
hx-trigger="change delay:1s"
hx-swap="none">
#
3.5 Indicators: hx-indicator
Shows loading states during requests:
<style>
/* htmx adds htmx-request class during requests */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
</style>
<!-- Indicator inside the triggering element -->
<button hx-get="/slow-operation">
Submit
<span class="htmx-indicator">Loading...</span>
</button>
<!-- Indicator elsewhere -->
<button hx-get="/data" hx-indicator="#spinner">Load</button>
<div id="spinner" class="htmx-indicator">
<img src="/spinner.gif" alt="Loading">
</div>
<!-- Disable button during request -->
<button hx-get="/data" hx-disabled-elt="this">
Click Me
</button>
#
3.6 History: hx-push-url
Updates browser history and URL:
<!-- Push new URL to history -->
<a hx-get="/products/123"
hx-target="#main"
hx-push-url="true">
View Product
</a>
<!-- Push custom URL -->
<button hx-get="/search?q=widgets"
hx-target="#results"
hx-push-url="/search/widgets">
Search Widgets
</button>
<!-- Replace current history entry (no back button) -->
<form hx-post="/step2"
hx-target="#wizard"
hx-replace-url="true">
History restoration: When users hit back/forward, htmx automatically restores the previous page state via AJAX (using the saved URL).
#
3.7 Out-of-Band Swaps: hx-swap-oob
Update multiple parts of the page from a single response:
<!-- In your response HTML: -->
<div id="main-content">
<!-- This goes to the normal target -->
<h1>Product Details</h1>
...
</div>
<!-- These swap themselves by ID, regardless of target -->
<div id="cart-count" hx-swap-oob="true">3 items</div>
<div id="notifications" hx-swap-oob="innerHTML">
<span class="badge">New message!</span>
</div>
OOB with different swap strategies:
<!-- Append to a list -->
<li id="todo-list" hx-swap-oob="beforeend">
<span>New todo item</span>
</li>
<!-- Replace specific element -->
<tr id="row-123" hx-swap-oob="outerHTML">
<td>Updated data</td>
</tr>
<!-- Delete an element -->
<div id="flash-message" hx-swap-oob="delete"></div>
Use cases:
- Update shopping cart count after adding item
- Show toast notifications
- Update multiple related data (edit user → update user list AND user count)
- Refresh CSRF tokens
#
4. Razor Pages Fit
htmx and Razor Pages are a natural pairing. Razor Pages already returns HTML. htmx just asks for smaller pieces of it.
#
4.1 Handlers as Endpoints
Razor Pages handlers become htmx endpoints:
// Pages/Products/Index.cshtml.cs
public class IndexModel : PageModel
{
// Standard page load: GET /Products
public void OnGet() { }
// htmx endpoint: GET /Products?handler=Search&q=widget
public IActionResult OnGetSearch(string q)
{
var results = _db.Products.Where(p => p.Name.Contains(q));
return Partial("_ProductList", results);
}
// htmx endpoint: POST /Products?handler=AddToCart
public IActionResult OnPostAddToCart(int productId)
{
_cart.Add(productId);
return Partial("_CartSummary", _cart);
}
// htmx endpoint: DELETE /Products?handler=Remove&id=123
public IActionResult OnDeleteRemove(int id)
{
_db.Products.Remove(id);
return Content(""); // Empty response for hx-swap="delete"
}
}
<!-- Pages/Products/Index.cshtml -->
<input type="search" name="q"
hx-get="?handler=Search"
hx-trigger="input changed delay:300ms"
hx-target="#product-list">
<div id="product-list">
<partial name="_ProductList" model="Model.Products" />
</div>
#
4.2 Partials for Fragments
Create partial views that render just the fragment htmx needs:
Pages/
├── Products/
│ ├── Index.cshtml # Full page
│ ├── Index.cshtml.cs # Page model with handlers
│ └── _ProductList.cshtml # Partial for htmx responses
│ └── _ProductCard.cshtml # Smaller partial
│ └── _CartSummary.cshtml # Another partial
<!-- Pages/Products/_ProductList.cshtml -->
@model IEnumerable<Product>
@foreach (var product in Model)
{
<partial name="_ProductCard" model="product" />
}
@if (!Model.Any())
{
<p class="text-muted">No products found.</p>
}
<!-- Pages/Products/_ProductCard.cshtml -->
@model Product
<div class="product-card" id="product-@Model.Id">
<h3>@Model.Name</h3>
<p>@Model.Price.ToString("C")</p>
<button hx-post="?handler=AddToCart"
hx-vals='{"productId": @Model.Id}'
hx-target="#cart-summary">
Add to Cart
</button>
</div>
#
4.3 Validation + Antiforgery
Antiforgery tokens: Required for POST/PUT/PATCH/DELETE requests:
<!-- Option 1: Include token in form -->
<form hx-post="?handler=Create">
@Html.AntiForgeryToken()
<!-- form fields -->
</form>
<!-- Option 2: Configure htmx to include token in all requests -->
<script>
document.body.addEventListener('htmx:configRequest', (e) => {
e.detail.headers['RequestVerificationToken'] =
document.querySelector('input[name="__RequestVerificationToken"]').value;
});
</script>
<!-- Place a token somewhere on the page -->
@Html.AntiForgeryToken()
Server-side validation with htmx:
public IActionResult OnPostCreate()
{
if (!ModelState.IsValid)
{
// Return the form partial with validation errors
Response.Headers["HX-Retarget"] = "#create-form";
Response.Headers["HX-Reswap"] = "outerHTML";
return Partial("_CreateForm", Input);
}
// Success: return updated list
return Partial("_ProductList", _db.Products.ToList());
}
<!-- _CreateForm.cshtml -->
@model CreateProductInput
<form id="create-form" hx-post="?handler=Create" hx-target="#product-list">
@Html.AntiForgeryToken()
<div class="form-group">
<label asp-for="Name"></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<button type="submit">Create</button>
</form>
Client-side validation (optional enhancement):
<!-- Inline validation as user types -->
<input asp-for="Email"
hx-get="?handler=ValidateEmail"
hx-trigger="blur changed"
hx-target="next .validation-message">
<span class="validation-message"></span>
public IActionResult OnGetValidateEmail(string email)
{
if (string.IsNullOrEmpty(email))
return Content("<span class='text-danger'>Email is required</span>");
if (!IsValidEmail(email))
return Content("<span class='text-danger'>Invalid email format</span>");
if (_db.Users.Any(u => u.Email == email))
return Content("<span class='text-danger'>Email already in use</span>");
return Content("<span class='text-success'>✓</span>");
}
#
Quick Reference Card
#
Mental Model Summary
Server owns the state. Your Razor Page model is the source of truth.
Server renders the UI. Partials return ready-to-display HTML fragments.
HTML is the contract. No JSON serialization, no client-side templates.
Links and forms drive state. htmx just makes more elements behave like links and forms.
Enhance progressively. Start with working forms, add htmx for smoothness.
┌───────────────────────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ ┌─────────────┐ click ┌─────────────┐ │
│ │ Button │ ───────────► │ htmx │ │
│ │ hx-get │ │ library │ │
│ └─────────────┘ └──────┬──────┘ │
│ │ │
│ AJAX Request │
│ GET /products?handler=Search │
└───────────────────────────────────────┼───────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ IndexModel.OnGetSearch(q) │ │
│ │ │ │
│ │ var results = _db.Products.Where(...); │ │
│ │ return Partial("_ProductList", results); │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ _ProductList.cshtml │ │
│ │ │ │
│ │ @foreach (var p in Model) │ │
│ │ { │ │
│ │ <div class="product">@p.Name</div> │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ HTML Fragment │
└───────────────────────────┼───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ htmx │ ──────────► │ <div id="product-list"> │ │
│ │ library │ swap │ <div>Widget A</div> │ │
│ └─────────────┘ innerHTML │ <div>Widget B</div> │ │
│ │ </div> │ │
│ └─────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
You're ready for the labs!