Adding a Live Filter to Server-Rendered HTML with Alpine.js — No Build Step Required
A practical look at using Alpine.js x-data, x-model, and x-show to add a live client-side filter to server-rendered HTML — with the security, CSP, and scale trade-offs spelled out.
03 Feb 2026, 00:44 UTC

You have a perfectly good server-rendered page — say, a list of a few hundred products, team members, or documentation entries — and the only thing it lacks is a search box that filters as the user types. The usual reflex is to reach for React or Vue, which means a bundler, a component tree, hydration, and a deployment pipeline change. For one filter, that is a lot of machinery.
Alpine.js is built for exactly this gap. It is a small directive-based library (roughly 15 kB minified and gzipped, though the exact size varies by version) that you drop into the page with a script tag. The thesis of this post: when interactivity is local and modest — filters, dropdowns, tabs, modals — Alpine's x-data / x-model / x-show trio gets you reactive behavior without a build step, and the trade-offs are worth understanding before you commit.
The mental model: one reactive object per DOM subtree
Alpine's core idea is x-data, an attribute that attaches a plain JavaScript object to a DOM element. Everything inside that element can read and mutate that state through other directives, and Alpine 3.x uses JavaScript Proxies to detect mutations — so assigning this.query = 'abc' just works, with no setters or explicit change notifications. (Alpine 2.x had a different reactivity model and several breaking behavioral differences, so pin your major version and say which one you are using; everything below assumes 3.x.)
The three directives that do most of the work in a filter:
x-model— two-way binds an input's value to a state property.x-show— toggles an element's visibility by flippingdisplay: nonebased on an expression.x-data— declares the state object and any helper methods.
That is genuinely most of the API surface you need for this task.
A worked example: filtering a server-rendered list
Assume your server template already renders the list items. You wrap the section in an x-data scope, bind the search input, and put an x-show expression on each item. The item's text can be emitted into a data-name attribute by the server so the filter expression has something to match against:
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<div x-data="{
query: '',
matches(el) {
return el.dataset.name.toLowerCase().includes(this.query.toLowerCase());
}
}">
<input type="search" x-model="query" placeholder="Filter team members…">
<ul>
<li data-name="Ada Lovelace" x-show="matches($el)">Ada Lovelace</li>
<li data-name="Grace Hopper" x-show="matches($el)">Grace Hopper</li>
<li data-name="Edsger Dijkstra" x-show="matches($el)">Edsger Dijkstra</li>
</ul>
</div>Run this anywhere a browser can load the CDN script — no npm install, no transpiler, no component registration. Typing in the input mutates query, Alpine re-evaluates each x-show expression, and non-matching items get display: none. Filtering happens entirely client-side, so there are no network round-trips per keystroke.
To verify it behaves as described: open devtools, type a query, and confirm there are no console errors. Then inspect a hidden <li> — you should see it still present in the DOM with an inline style="display: none;". That inspection matters, because it demonstrates the next point.
The trade-offs you are accepting
x-show is not a security boundary. Hidden items remain in the DOM and in the page source. If the filter is ever about access control — "users should only see their own records" — the filtering must happen server-side. Alpine's hiding is purely cosmetic.
Content-Security-Policy. Standard Alpine evaluates directive expressions in a way that violates a strict CSP unless you allow 'unsafe-eval'. Alpine 3.x ships a separate CSP build with a more constrained API (Alpine.data() registration instead of inline expressions). Test against your production CSP header early; discovering this at deploy time is unpleasant.
Scale ceiling. Client-side filtering means every item is in the initial HTML. A few hundred items is fine; thousands of rows make the page heavy and the per-keystroke re-evaluation noticeable. Past that point, server-side search (or a hybrid where Alpine manages the input and fetches results) is the better call.
Version drift. The @3.x.x CDN tag floats within the major version. For anything production-bound, pin an exact version and check the official release notes before upgrading — directive syntax and behavior have changed across 3.x releases.
Where this decision lands
Alpine earns its place when the page is already server-rendered and the interactivity is local: a filter, a dropdown, a tab set, a modal. You keep your existing templates, skip the toolchain, and add maybe a dozen attributes. If you later need persisted UI preferences (theme, sidebar state), the official @alpinejs/persist plugin adds a $persist magic for that without changing the model.
The actionable next step: pick one page in your app with a modest interactive need, add Alpine from a pinned 3.x CDN version, and implement it with devtools open. Check the console, inspect what x-show actually does to the DOM, and run it under your real CSP header. Fifteen minutes of that tells you more than any comparison chart — including whether your use case is small enough for Alpine or big enough to justify something heavier.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.