Hypermedia Systems [2026]: Modern Web Apps with htmx
Bottom Line
As of May 22, 2026, the safest production path is to build against stable htmx 2.0.10 patterns, not a nonexistent 3.0 API. Use boosted navigation, fragment endpoints, and out-of-band swaps to get SPA-like UX while keeping HTML and routing on the server.
Key Takeaways
- ›Stable htmx docs in May 2026 point to version 2.0.10, while the next major is 4.0 beta.
- ›Use HX-Request to branch between full-page and fragment responses from the same route.
- ›Boosted links preserve URLs and back-button behavior without adding a client-side router.
- ›Use hx-swap-oob for counters, badges, and flash messages that should update outside the main target.
Hypermedia is back in the architecture conversation because teams want simpler delivery models, lower client complexity, and fewer hydration bugs. htmx is one of the cleanest ways to get there, but there is an important 2026 reality check: the official project skipped a 3.0 release path. As of May 22, 2026, the public docs load 2.0.10, and the next major line is 4.0 beta. So this tutorial focuses on stable patterns you can ship today.
Prerequisites
What you need
- Node.js 20+ and npm
- Comfort with basic HTML, forms, and Express-style routing
- A browser with DevTools open so you can inspect network requests
- A server-first mindset: the server owns routing, rendering, and mutation logic
What you will build
A small task app with boosted navigation, live filtering, form posts, and an out-of-band counter update. The point is not the UI. The point is the architecture: one server, HTML fragments, and almost no custom client JavaScript.
Bottom Line
Treat htmx as an HTML transport and coordination layer, not as a client framework. If you model routes and fragments cleanly, you get interactivity, URL history, and progressive enhancement without building a SPA shell.
The design rule in this tutorial is simple: every interaction should still make sense as plain hypertext first. htmx then upgrades that interaction with hx-boost, hx-get, hx-post, hx-select, and hx-swap-oob.
Step 1: Scaffold a server-rendered shell
- Initialize a minimal project.
npm init -y
npm i express
Add "type": "module" and a dev script to package.json:
{
"type": "module",
"scripts": {
"dev": "node --watch app.js"
}
}
- Create
app.jswith a full-page shell and a fragment-aware route layer.
import express from 'express';
const app = express();
app.use(express.urlencoded({ extended: true }));
let tasks = [
{ id: 1, title: 'Write release notes', done: false },
{ id: 2, title: 'Review billing alert', done: true },
{ id: 3, title: 'Ship onboarding copy', done: false }
];
const isHx = (req) => req.get('HX-Request') === 'true';
const openCount = () => tasks.filter((task) => !task.done).length;
function shell(content, title = 'Tasks') {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="htmx-config" content='{"historyRestoreAsHxRequest":false}' />
<title>${title}</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous"></script>
</head>
<body>
${layout(content)}
</body>
</html>`;
}
app.listen(3000, () => {
console.log('http://localhost:3000');
});
The important line is the historyRestoreAsHxRequest config. When you return partial responses based on the HX-Request header, you do not want browser history restoration to masquerade as a fragment request.
Step 2: Add boosted navigation and fragment endpoints
- Wrap your app in a layout where links are still normal links.
function layout(content) {
return `
<header>
<h1>Ops Console</h1>
<nav hx-boost="true" hx-target="#app" hx-swap="innerHTML show:top">
<a href="/">Dashboard</a>
<a href="/tasks">Tasks</a>
</nav>
</header>
<main id="app">${content}</main>
`;
}
function dashboardPage() {
return `
<section>
<h2>Dashboard</h2>
<p>Open tasks: <strong id="open-count">${openCount()}</strong></p>
</section>
`;
}
- Render the tasks page as a normal page, then let htmx request only the fragment it needs.
function taskRow(task) {
return `
<li id="task-${task.id}">
<span>${task.title}</span>
<form hx-post="/tasks/${task.id}/toggle" hx-target="#task-${task.id}" hx-swap="outerHTML">
<button>${task.done ? 'Reopen' : 'Complete'}</button>
</form>
</li>
`;
}
function taskPanel(query = '') {
const filtered = tasks.filter((task) =>
task.title.toLowerCase().includes(query.toLowerCase())
);
return `
<section id="task-panel">
<form action="/tasks" method="get">
<input
type="search"
name="q"
value="${query}"
placeholder="Filter tasks"
hx-get="/tasks"
hx-trigger="input changed delay:300ms, search"
hx-target="#task-panel"
hx-select="#task-panel"
hx-include="closest form"
hx-push-url="true" />
</form>
<ul id="task-list">${filtered.map(taskRow).join('')}</ul>
</section>
`;
}
function tasksPage(query = '') {
return `
<section>
<h2>Tasks (<strong id="open-count">${openCount()}</strong> open)</h2>
<form action="/tasks" method="post" hx-post="/tasks" hx-target="#task-panel" hx-select="#task-panel" hx-swap="outerHTML">
<input name="title" placeholder="New task" required />
<button>Add task</button>
</form>
${taskPanel(query)}
</section>
`;
}
app.get('/', (req, res) => {
const body = dashboardPage();
res.send(isHx(req) ? body : shell(body, 'Dashboard'));
});
app.get('/tasks', (req, res) => {
const query = String(req.query.q || '');
const body = tasksPage(query);
res.send(isHx(req) ? body : shell(body, 'Tasks'));
});
This is the core hypermedia move. Your routes stay server-owned, but the client can request narrower HTML slices. If the browser has no JavaScript, the links and forms still resolve as regular requests. That progressive fallback is why hx-boost is so valuable.
Step 3: Add mutations and out-of-band swaps
- Handle writes on the server exactly as you would in a classic app.
app.post('/tasks', (req, res) => {
const title = String(req.body.title || '').trim();
if (!title) {
return res.status(422).send(isHx(req) ? tasksPage('') : shell(tasksPage(''), 'Tasks'));
}
tasks.unshift({ id: Date.now(), title, done: false });
const body = tasksPage('');
res.send(isHx(req) ? body : shell(body, 'Tasks'));
});
- Use hx-swap-oob when one action should update UI outside the target element.
app.post('/tasks/:id/toggle', (req, res) => {
const task = tasks.find((item) => item.id === Number(req.params.id));
if (!task) return res.status(404).send('Not found');
task.done = !task.done;
res.send(`
${taskRow(task)}
<strong id="open-count" hx-swap-oob="outerHTML">${openCount()}</strong>
`);
});
The request targets only the clicked row, but the response also patches the badge in the page header. That is where hypermedia gets architectural leverage: the server decides the new UI state once, and the browser applies it in multiple places without a local state store.
- Use normal routes, not RPC-style action names.
- Return HTML that already reflects the post-mutation truth.
- Prefer one small fragment per concern: row, panel, badge, flash message.
- Reach for custom JavaScript only after HTML responses stop being expressive enough.
Verify and Troubleshoot
Verification and expected output
- Run
npm run devand openhttp://localhost:3000. - Click
Tasks. The URL should change to/tasks, but only#appshould swap. - Type into the search box. You should see GET requests to
/tasks?q=..., and only#task-panelshould refresh. - Click
Completeon a row. The row should change in place, and#open-countshould update even though it lives outside the row target. - Disable JavaScript and refresh. Navigation and forms should still work as full-page requests.
Troubleshooting: top 3 issues
- You only see a fragment after using Back: set
historyRestoreAsHxRequesttofalsewhen your server branches onHX-Request. - Boosted links replace more of the page than expected: set an explicit
hx-targetsuch as#appinstead of relying on defaults. - Out-of-band swaps do nothing: the response element must include the matching
id, and the markup must be valid for the swap target.
For production debugging, capture the exact response body and sanitize it before sharing. If those fragments contain user data, run them through a masking workflow rather than pasting raw HTML into tickets or chat.
What's Next
Once this pattern is stable, the next improvements are architectural, not cosmetic.
- Add CSRF protection with standard server controls and, where needed, hx-headers or hidden form fields.
- Introduce fragment caching for slow sidebars, summaries, and dashboards.
- Use server-sent events or WebSockets only for genuinely real-time regions, not for every interaction.
- Split templates into reusable fragment functions so each route can return full pages or partials from the same rendering primitives.
- Evaluate 4.0 beta separately in a branch, but keep production code anchored to the stable 2.0.10 behavior set until your team finishes compatibility testing.
The practical lesson is that hypermedia systems are not nostalgic architecture. In 2026, they are a credible way to cut frontend complexity while preserving modern UX expectations. htmx works best when you let the server speak HTML fluently and keep the browser focused on swapping, history, and form semantics.
Frequently Asked Questions
Is htmx 3.0 actually available in 2026? +
2.x behavior unless you are explicitly testing beta builds.How do I return full pages and partials from the same route with htmx? +
HX-Request header on the server. Return the full HTML shell for normal requests and a fragment for htmx requests; if you do this, set historyRestoreAsHxRequest to false so history restoration does not accidentally request fragments.When should I choose htmx over React or Next.js? +
How do I handle authentication and CSRF with htmx? +
Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.