
Swisspreneur
570+ podcast episodes turned into instant, cited answers
Read the case studyYou have a server-rendered page. Rails, Laravel, Django, or a static site. It needs a dropdown, a modal, and a pair of tabs. Nothing more.
jQuery can do it, but you'll end up writing selectors and toggling classes by hand. React can do it too, but then you add a build step, a bundle, and a second way to render HTML. That's a lot of machinery for three small interactions.
Alpine.js sits between those two. This post covers what Alpine is, the parts you'll use every day, and the question that decides everything else: when is Alpine the right tool, and when isn't it?
Use Alpine.js when the server renders your HTML and you need small, local interactions on top of it: dropdowns, modals, tabs, toggles, and client-side filters. It works best with Laravel and Livewire, with htmx, and on static or CMS-driven sites.
Don't use Alpine.js for a single-page app with client routing, for a UI with a large shared state, or when your team needs typed components and component tests. In those cases, React or Vue costs more up front and less over time.
The rest of this post shows why.
Alpine.js is a small JavaScript framework that lets you add behavior directly in your HTML, with attributes. You declare state on an element, and Alpine keeps the page in sync with that state.
Its reactivity comes from @vue/reactivity, the same engine that powers Vue. If you've written Vue templates, the syntax will look familiar: x-on:click (or @click), x-bind:class (or :class), x-model, x-for.
Three facts matter most:
The fastest way is the CDN build. Add it to your <head> with defer:
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.5/dist/cdn.min.js"></script>
Pin the exact version in production. An unpinned @3.x.x URL can change under you, and it won't ask first.
If you already have a bundler (Vite, esbuild, webpack), install the package instead:
npm install alpinejs
import Alpine from "alpinejs";
window.Alpine = Alpine;
Alpine.start();
Call Alpine.start() once per page, after you register your components and plugins.
We'll build one dropdown and add one directive at each step.
x-data turns an element into an Alpine component and gives it state. Everything inside the element can read that state.
<div x-data="{ open: false }">
...
</div>
x-on:click (short form @click) runs an expression on a click. x-show shows or hides an element with CSS display.
<div x-data="{ open: false }">
<button @click="open = !open">Options</button>
<ul x-show="open">
<li><a href="/account">Account</a></li>
<li><a href="/logout">Log out</a></li>
</ul>
</div>
Event modifiers handle the common cases for you. @click.outside="open = false" closes the menu when the user clicks anywhere else. @keydown.escape closes it from the keyboard.
x-bind (short form :) sets an attribute from an expression. Use it for classes, disabled, and accessibility attributes:
<button @click="open = !open" :aria-expanded="open">Options</button>
x-transition adds a fade and scale when x-show changes. x-cloak hides the element until Alpine starts, so the menu doesn't flash open on page load. x-cloak needs one line of CSS:
[x-cloak] { display: none !important; }
Here is the complete dropdown:
<div x-data="{ open: false }" @click.outside="open = false" @keydown.escape="open = false">
<button @click="open = !open" :aria-expanded="open">Options</button>
<ul x-show="open" x-transition x-cloak>
<li><a href="/account">Account</a></li>
<li><a href="/logout">Log out</a></li>
</ul>
</div>
The complete dropdown after a click on Options.
That's the whole component. No JavaScript file, no build, no selectors.
Four more directives cover most other cases:
x-model binds an input to a property, in both directions.x-text sets the text content of an element from an expression.x-for repeats a template for each item in an array.x-if adds or removes an element from the DOM (x-show only hides it).x-for and x-if must sit on a <template> tag, and that template must have exactly one root element. Give each x-for item a :key:
<div x-data="{
name: '',
frameworks: [
{ id: 1, name: 'Rails' },
{ id: 2, name: 'Laravel' },
{ id: 3, name: 'Django' },
],
}">
<label>
Your name
<input type="text" x-model="name">
</label>
<p>Hello, <span x-text="name"></span></p>
<ul>
<template x-for="framework in frameworks" :key="framework.id">
<li x-text="framework.name"></li>
</template>
</ul>
<template x-if="name.length > 10">
<p>That is a long name.</p>
</template>
</div>
x-model updates the greeting as you type. x-if adds the last line after 10 characters.
x-init runs once, when the component starts. x-effect runs again each time a value it reads changes. Use them for small jobs, such as a first fetch or a log line, and keep anything longer in a component (see below).
Alpine also has a few "magic" properties, each with a $ prefix:
$refs gives you direct access to an element you marked with x-ref, for example to focus an input.$dispatch sends a browser event that another component can listen to, with @notify.window.$watch runs a function when one property changes.$nextTick waits until Alpine updates the DOM.$el is the current element.When an inline object grows past a few lines, move it into Alpine.data(). This gives the component a name, and you can reuse it on several elements:
document.addEventListener("alpine:init", () => {
Alpine.data("dropdown", () => ({
open: false,
toggle() {
this.open = !this.open;
},
}));
});
<div x-data="dropdown">
<button @click="toggle">Options</button>
</div>
For state that several components share, such as a cart count or a theme, use Alpine.store(). Register the store in an alpine:init listener, as you do with Alpine.data():
document.addEventListener("alpine:init", () => {
Alpine.store("cart", { count: 0 });
});
Any Alpine component can then read the store with $store. Alpine only reads directives inside an element with x-data, so a standalone element needs an empty x-data. Without it, the span stays blank and Alpine doesn't tell you why:
<span x-data x-text="$store.cart.count"></span>
Alpine also ships official plugins for common needs: Persist (keep state in localStorage), Focus (focus traps for modals), Intersect (react when an element scrolls into view), Collapse, Mask (input masks), Anchor (position a popover next to its trigger), Morph, Sort, and Resize. With the CDN build, load each plugin's script before Alpine's core script.
This component combines most of the above: a search input, a filtered list, and a panel that opens when the user selects a contact.
<div x-data="contactList">
<label>
Search contacts
<input type="search" x-model="query">
</label>
<p x-show="filtered.length === 0">No contact matches your search.</p>
<ul>
<template x-for="contact in filtered" :key="contact.id">
<li>
<button type="button" @click="select(contact)" x-text="contact.name"></button>
</li>
</template>
</ul>
<template x-if="selected">
<section>
<h2 x-text="selected.name"></h2>
<p x-text="selected.email"></p>
<button type="button" @click="selected = null">Close</button>
</section>
</template>
</div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("contactList", () => ({
query: "",
selected: null,
contacts: [
{ id: 1, name: "Ada Lovelace", email: "ada@example.com" },
{ id: 2, name: "Alan Turing", email: "alan@example.com" },
{ id: 3, name: "Grace Hopper", email: "grace@example.com" },
],
get filtered() {
const query = this.query.toLowerCase();
return this.contacts.filter((contact) =>
contact.name.toLowerCase().includes(query)
);
},
select(contact) {
this.selected = contact;
},
}));
});
</script>
The search for g hides Ada Lovelace. A click on Grace Hopper opens the detail panel.
The HTML describes what the user sees. The Alpine.data() block holds the logic. The filtered getter recalculates on its own whenever query changes. In a real app, the server would render the contacts array into the page, or Alpine would fetch it in init().
This is the main use case. Your framework renders HTML and handles routing, forms, and data. Alpine adds the parts that must react instantly in the browser: menus, modals, tabs, accordions, and "show more" toggles. You keep one source of truth for the markup, and you don't ship a second rendering layer.
Livewire 3 includes Alpine. Livewire handles the server round trips, and Alpine handles the state that must not wait for the server, such as an open menu. Tailwind, Alpine, Laravel, and Livewire together are often called the TALL stack.
htmx and Alpine solve different problems, so they get along well. htmx sends requests and swaps HTML from the server. Alpine keeps state that lives only in the browser.
<form hx-post="/search" hx-target="#results" x-data="{ advanced: false }">
<label>
Search contacts
<input name="q" type="search">
</label>
<button type="button" @click="advanced = !advanced" :aria-expanded="advanced">Advanced filters</button>
<fieldset x-show="advanced">
<label><input name="archived" type="checkbox"> Include archived contacts</label>
</fieldset>
</form>
<div id="results"></div>
Alpine opens the filters without a request. htmx sends the search and puts the server's HTML in the results.
One thing to know: when htmx swaps a fragment, Alpine starts the components inside it from scratch, so any state they held is gone. Keep Alpine state outside the area that htmx replaces, or move it to a store.
On a static site, a WordPress or Shopify theme, or a quick prototype, there's often no build pipeline at all, and nobody wants to add one for a menu. One script tag gives you reactive behavior without it.
Alpine.data() and test those functions, but the template stays untyped.new Function, which needs unsafe-eval. Alpine offers a CSP build (@alpinejs/csp) that works without it, but that build doesn't support arrow functions, template literals, spread, globals such as console or Math, or x-html in expressions. You must move that logic into Alpine.data().Vanilla JavaScript. For one toggle, plain JavaScript is fine. Once you have several components that each keep state, you'll start writing your own small framework. Alpine is that framework, already written and tested.
jQuery. jQuery is imperative: you find elements and change them. Alpine is declarative: you describe the state, and the DOM follows. Alpine code is easier to read in the HTML, because the behavior sits next to the element it changes.
Stimulus. Stimulus, from the Rails world, also adds behavior to server HTML. It keeps logic in JavaScript controller classes and uses HTML attributes only to connect them. Alpine puts more logic directly in the markup. Teams that prefer a strict split between HTML and JavaScript often choose Stimulus.
petite-vue. petite-vue comes from the Vue team and has a similar goal, with an even smaller size. Alpine has more directives, the official plugins, and a larger community.
htmx. htmx and Alpine don't compete. htmx moves HTML between the server and the page. Alpine manages state in the browser. Many projects use both.
React and Vue. These frameworks render the UI themselves and give you components, types, a large ecosystem, and test tools. They also need a build step and own the markup. Choose them when the browser does most of the work. Choose Alpine when the server does.
defer. Without it, Alpine can run before the page has parsed, and your components don't start.x-cloak and its CSS rule.x-for or x-if on a normal element. Both directives must sit on a <template> tag with one root element inside.Alpine.data(). It is easier to read and to test.x-html with user content. x-html inserts raw HTML. Never use it with content that a user can control, because that opens your page to cross-site scripting (XSS). Use x-text instead.Two more caught me while I built the examples for this post. Neither one shows up in a code review, because the code looks right:
Alpine.store() before Alpine loads. Alpine's script has defer, so a store in a plain inline script runs first and throws Alpine is not defined. Register stores in an alpine:init listener.$store outside a component. Alpine ignores an element that has no x-data on it or around it. The text stays empty and the console stays quiet. Add x-data to the element.Answer these five questions:
If you answered yes to all five, use Alpine.js. If you answered no to the first or the third, look at React or Vue instead. A mix of answers usually means Alpine for now, with a clear point at which you'd move.
The official Alpine.js documentation is short and complete. Read the page for each directive before you use it in production. If you came from Vue, our post on building a web extension with Vue shows the other end of the scale: when the browser does the work, you want a full framework.
The right tool depends on where your HTML comes from and how much the browser has to do. Streaver's full-cycle product engineering team makes that call with you, and then builds the product. If you're weighing Alpine against React for your next release, let's talk.
Start Here: Alpine.js, CSP: Alpine.js, data(): Alpine.js, htmx documentation: htmx, Introducing Alpine.js: A Tiny JavaScript Framework: Smashing Magazine

570+ podcast episodes turned into instant, cited answers
Read the case study
Building a $1M product for $125K with a non-technical CEO at the keyboard
Read the case study
Five tools became one platform for an entire robotics ecosystem
Read the case study
A hands-on guide to automating EC2 patching with AWS Systems Manager (SSM) and Patch Manager — from prerequisites and SSM Agent setup to patch baselines, scan associations, and compliance visibility.

The cybersecurity best practices that matter in 2026 — MFA and identity-first security, patch management, awareness training, Zero Trust, and tested incident response — and the AI-driven threats they defend against.

How we rebuilt a fragile, race-condition-prone Universal Login into a reliable, scalable identity platform on AWS Cognito — using managed login, event-driven flows, and an invisible migration of 150,000+ users.
Streaver embeds senior product teams inside companies building AI-native software, from whiteboard to live customers.
Talk to us