I replaced Alpine.js in my app with this alternative 🔥
4 min read • 6/17/2025
7 comments • 107 views

Typically, things like Alpine.js are compared to frameworks like Next.js because of their advantages over them.
Now we’re diving into something far more relevant. Both modules (Alpine.js and HMPL) work directly with HTML, and both are meant to bring logic closer to markup. That makes this comparison much more grounded.
Today, we're replacing Alpine.js in a project with HMPL.
Let’s get started! 🏎️
⚙️ From simple examples
Let’s start with the most basic clicker example. With Alpine.js, you’d probably write something like this:
<div
x-data
x-fetch:clicker="{
url: '/click',
method: 'POST'
}"
x-init="$fetch.clicker"
class="clicker-container"
>
<h1>Alpine Clicker</h1>
<div class="click-count" x-text="$fetch.clicker.data?.count ?? 0"></div>
<button class="click-button" @click="$fetch.clicker">Click</button>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewbox="0 0 24 24" class="highlight-action crayons-icon highlight-action--fullscreen-off"><title>Exit fullscreen mode</title>
<path d="M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z"></path>
It looks great and, most importantly, works. Now let’s look at the HMPL.js version:
<body></body>
<script>
const templateFn = hmpl.compile(`
<div class="clicker-container">
<h1>HMPL Clicker</h1>
<div class="click-count" id="counter">
{{#request src="/click" after="click:#btn"}}
{{/request}}
</div>
<button id="btn">Click!</button>
</div>
`);
const clicker = templateFn().response;
document.querySelector("body").append(clicker);
</script>
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewbox="0 0 24 24" class="highlight-action crayons-icon highlight-action--fullscreen-off"><title>Exit fullscreen mode</title>
<path d="M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z"></path>
As you can see, we have two different philosophies: Alpine.js works declaratively inside the HTML itself, while HMPL compiles templates and dynamically renders them via JS. But the interface is conceptually similar - actions tied to events, data updates in the DOM. The difference lies in control and flexibility.
Also, it would be great if you supported the project with your star! Thanks ❤️!
🔍 Built-in limitations
Alpine.js, while powerful in its simplicity, isn't inherently built around a server-oriented paradigm. It's a client enhancement tool meant for light interactivity, not full templating or server-side rendering. This makes it great for progressive enhancement but somewhat limiting when building applications that rely heavily on server-driven content.
Because Alpine doesn't natively treat the server as a central part of the UI lifecycle, integrating complex request flows or handling server state often feels bolted on. Even with plugins like @alpinejs/fetch
, you're still mostly wiring client-side logic, manually coordinating fetch calls, and managing reactive state in the browser.
This client-heavy approach can get cumbersome as your app scales. Form submissions, conditional loading, dynamic content updates, all require increasingly verbose JavaScript bindings or hacks involving x-init
, external methods, and nested directives. Alpine gives you control, but often without abstraction.
In contrast, a templating language like HMPL is designed with the server at the core. It embraces server-rendered HTML and fetch-driven reactivity natively. You don't have to manually coordinate state or requests - it's declarative, context-aware, and built for scenarios where the server and the UI work in unison.
🔗 Complex example
Let’s take a more advanced example, a registration form with loading indicator. Here's what it might look like in Alpine.js:
<div x-data="formComponent()" id="wrapper">
<form @submit.prevent="submit">
<div class="form-example">
<label for="login">Login: </label>
<input type="text" name="login" id="login" x-model="form.login" required /><br/>
<label for="password">Password: </label>
<input type="password" name="password" id="password" x-model="form.password" required />
</div>
<div class="form-example">
<input type="submit" value="Register!" />
</div>
</form>
<template x-if="loading">
<div class="indicator">
<p>Loading...</p>
</div>
</template>
<div id="response" x-text="responseText"></div>
</div>
<script>
function formComponent() {
return {
form: { login: '', password: '' },
loading: false,
responseText: '',
async submit() {
this.loading = true;
const res = await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(this.form),
headers: { 'Content-Type': 'application/json' },
});
const text = await res.text();
this.responseText = text;
this.loading = false;
}
};
}
</script>
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewbox="0 0 24 24" class="highlight-action crayons-icon highlight-action--fullscreen-off"><title>Exit fullscreen mode</title>
<path d="M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z"></path>
Now let's see how the same can be done in HMPL:
<body></body>
<script>
const templateFn = hmpl.compile(`
<div id="wrapper">
<form onsubmit="event.preventDefault();" id="form">
<div class="form-example">
<label for="login">Login: </label>
<input type="text" name="login" id="login" required /><br/>
<label for="password">Password: </label>
<input type="password" name="password" id="password" required />
</div>
<div class="form-example">
<input type="submit" value="Register!" />
</div>
</form>
<p>
{{#request
src="/api/register"
after="submit:#form"
repeat=true
}}
{{#indicator trigger="pending"}}
<div class="indicator">
<p>Loading...</p>
</div>
{{/indicator}}
{{/request}}
</p>
<div id="response">{{response.body}}</div>
</div>
`);
const initFn = (ctx) => {
const event = ctx.request.event;
return {
body: new FormData(event.target, event.submitter),
credentials: 'same-origin',
};
};
const result = templateFn(initFn);
document.body.append(result.response);
</script>
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewbox="0 0 24 24" class="highlight-action crayons-icon highlight-action--fullscreen-off"><title>Exit fullscreen mode</title>
<path d="M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z"></path>
With HMPL, we gain granular control. You can intercept the event, access the FormData
, customize headers, control indicators, all within a declarative template structure.
📊 Size comparison
Let's not ignore the size difference.
Alpine.js is incredibly small, and with minimal functionality it stays tiny. Here’s a quick visual:
📦 Alpine.js:
📦 HMPL:
So yes, Alpine lose the size game. But the gap isn’t massive — and if you're building something more interactive or server-driven, the slight size increase might be worth the added flexibility.
Also, in a broader comparison, HMPL still performs surprisingly well:
Of course, the smaller the feature set, the smaller the final JS bundle, but that also means you’ll need to write more imperative logic outside the HTML.
✅ Conclusion
Alpine.js is a great choice for projects that need simple, inline reactivity without the complexity of larger frameworks. But in this article, I wanted to present an alternative.
HMPL offers a more modern, customizable approach to server-driven UI. If your app requires dynamic data handling, advanced request logic, or full fetch control — HMPL might be a better fit.
In the end, it’s all about the right tool for the right job.
Thanks for reading this article ❤️!
What’s your take on this comparison? Let me know in the comments!
Useful links: