There is a gap in the middle of front-end work that nobody talks about much.

On one side, a server-rendered PHP application: fast to build, easy to host, completely crawlable, and every click is a full page reload with a white flash. On the other, a React or Next.js front end: smooth navigation and real interactivity, at the cost of a build pipeline, an API layer, duplicated validation and a second codebase to maintain.

Plenty of business applications want something in between. They need the page to stop flashing and a few interactions to feel immediate. They do not need an application running in the browser.

That gap is what htmx and Alpine.js fill. We run both on this site and on a client booking platform, so what follows is what the combination is actually like once it is live — including the parts that took real work.

What each one does

They solve different problems, which is why they pair well.

htmx lets HTML make HTTP requests. Put hx-boost="true" on a nav element and its links stop doing full page loads: htmx fetches the next page in the background and swaps a region of the current document instead. The server keeps returning ordinary, complete HTML pages. Nothing becomes JSON.

Alpine.js handles small amounts of local state — a dropdown's open flag, a dismissible notice, a toggled panel. It is roughly what you would otherwise write as a handful of ad-hoc event listeners, with a structure around it.

Neither needs a build step. Both are a single file you include. There is no bundler, no transpiler, no node_modules in the deployment, and nothing to rebuild before you can see a change.

What this site actually does

Concretely, because vague descriptions of this stack are not useful.

Every page is rendered by PHP exactly as it was before. The <body> carries the swap configuration, and the header nav, <main> and footer carry hx-boost. When you click an internal link:

  1. htmx fetches the target URL — the same full HTML page a normal visit gets.
  2. It swaps only <main id="content"> out of the response into the current document.
  3. The URL is pushed to history.

The header, the footer and their event handlers are never touched. There is no client-side router, no route table, and no second description of the site's structure to keep in sync. The server remains the only thing that knows what a URL means.

Every boosted link keeps its real href. Middle-click, ctrl-click, "open in new tab", a crawler, a visitor with JavaScript disabled or blocked — all get the ordinary page. That is not a fallback we added; it is what the markup does when the script does not run.

The parts that needed real work

This is the section most articles on htmx skip, and it is the one that decides whether you should use it.

A swap is not a page load, and the document knows it

After a swap, the browser is showing content from /services while <head> still describes the previous page. Nothing breaks visually and everything else is wrong: the title, the meta description, the canonical, the Open Graph tags and the JSON-LD all belong to a page the visitor has left.

Crawlers never see this — they request full pages — but anything reading the live DOM does, and so does your analytics. We copy the incoming document's metadata and structured data across on every swap, and set the new title before the URL is pushed, because a history-change page view reads document.title at the moment of the push and would otherwise file the new URL under the old page's name.

This is maybe sixty lines of code. It is also the part that is quietly missing from most htmx implementations we have looked at.

Scripts inside swapped content never run

htmx does not execute <script> tags it swaps in, and you should not want it to. So any page-specific JavaScript has to live in a file loaded once, and has to be able to start and stop on a region of the DOM rather than on page load.

We settled on a small convention: each feature exposes initRegion(root) and teardownRegion(root), and the swap calls teardown on the outgoing <main> and init on the incoming one. Scroll reveals, the case-study dialogs, the contact form and the blog's contents list all follow it.

The failure this prevents is subtle and nasty: without teardown you accumulate IntersectionObservers and event listeners on detached nodes, one set per navigation, and the page gets slower the longer someone browses. If you take one thing from this article, take that.

Decide what you refuse to swap

A swap assumes the incoming page is compatible with the current one. Ours refuses — and falls back to a normal page load — unless the response has a <main id="content">, is marked as participating, contains no script inside <main>, and needs no stylesheet or script the current page has not already loaded.

That last condition matters. Without it, a page with its own stylesheet swaps in unstyled and the visitor sees a broken layout with no obvious cause. Error pages deliberately opt out entirely, so a 404 is always a real page load with a real status.

Back and Forward

htmx can restore a cached snapshot on history navigation. We turned that off (historyCacheSize = 0, refreshOnHistoryMiss = true) so Back and Forward re-request from the server. A snapshot restores the body but carries a <head> that may belong to a different page, and the complexity of keeping snapshots coherent was not worth it for a site where pages are cheap to re-fetch.

Focus and scroll

A full page load resets scroll and focus. A swap does neither, so you do it yourself: scroll to the top (or to the #hash the link asked for, offset for the sticky header), and move focus to the new <main> so keyboard and screen-reader users start at the new content instead of on a link that no longer exists.

Easy to implement, easy to forget, and the sort of omission that makes a site unusable for some visitors while looking perfect to everyone else.

Running both under a strict Content-Security-Policy

This is where the combination gets genuinely opinionated, and where we think it beats the alternatives.

This site sends style-src 'self' with no 'unsafe-inline', and no 'unsafe-eval' anywhere. That has two consequences:

Alpine's standard build will not run. It compiles directive expressions with new Function(), which 'unsafe-eval' exists to permit. The CSP build does not: components are registered with Alpine.data() and markup refers to properties and methods by name rather than carrying expressions. It is a real constraint — you stop writing logic in attributes — and in practice it pushed our components somewhere better, because the behaviour ends up in a JavaScript file instead of scattered through the HTML.

htmx needs two settings changed. Its request indicator injects a <style> element, which the policy blocks, so that is disabled and the indicator styles live in the stylesheet instead. Its eval-dependent features are switched off explicitly, along with script execution in swapped content and cross-origin requests.

Both libraries can run under a policy with no unsafe-inline and no unsafe-eval. Most front-end stacks cannot make that claim without a nonce pipeline.

What it costs

An honest accounting.

Two files, about 120 KB uncompressed. htmx 2.0.11 is 52 KB minified, the Alpine CSP build 72 KB, both well under half that over the wire. Not nothing — but no build output, no framework runtime, and no hydration.

You write the glue. Metadata syncing, the init/teardown convention, swap eligibility, focus and scroll — that is a few hundred lines you own. It is written once and it is plain JavaScript that any developer can read, but it is not free, and nobody's getting-started guide mentions it.

Debugging is event-driven. When something goes wrong mid-swap you are reading htmx:beforeSwap and htmx:afterSettle rather than a component tree. Fine once you know the events; unfamiliar at first.

It is not a replacement for React. For a genuinely application-like interface — a drag-and-drop scheduler, a live-updating dashboard, a complex editor with interdependent fields — you want a framework built for that, and pretending otherwise wastes everyone's time. We cover where that line falls in PHP vs Next.js for business web applications.

When we reach for it

Good fit: an existing server-rendered application that works, where the complaint is that it feels dated. This is the big one. You keep every page, every route, every validation rule and every line of business logic, and add two script tags and some attributes. Compared with rebuilding the same application as a single-page app plus an API, it is a rounding error — which is why it comes up constantly in modernisation work, where the alternative being quoted is a full rewrite.

Good fit: content-led sites where crawlability is not negotiable and the navigation should still feel quick. Every page is complete HTML from the server; the smoothness is additive.

Good fit: small teams. One language, one codebase, one place where validation lives. No API contract to keep in step with itself.

Poor fit: genuinely interactive applications, offline-first products, anything where the browser needs to hold substantial state. Use the right tool.

Poor fit: a team that already knows React well and has a working pipeline. The gain does not justify relearning how to build things.

Would we do it again

Yes — with the caveat that the interesting work was not adding the libraries. It was the hundred-odd decisions underneath: what to refuse to swap, what to do with the document's metadata, how features start and stop, what happens to focus.

Get those right and you have a server-rendered application that navigates like an app, works with JavaScript off, runs under a strict CSP, and has no build step between you and production. Get them wrong and you have a site that feels fine for five minutes and leaks listeners for the rest of the session.

The pattern on this site came out of a client booking platform first — the Alam Al-Munawara tour marketplace, which runs the same approach on the same kind of hosting — and we brought it back here afterwards. Both are PHP applications that nobody had to rewrite. You can see the rest of what we build on over on Technologies.