Why Alpine.js Replaced React for Content-Driven Sites
When I migrated a farm directory site off Next.js, React went with it. Every dropdown, modal, toggle, and form validation that used to be a React component is now a plain HTML element with an x-data attribute on it. The visible behavior didn’t change. The client-side JavaScript running it went from a framework with its own rendering engine to a single script tag.
This isn’t a claim that Alpine.js beats React. For a specific and very common shape of site, content-driven, mostly server-rendered, with small pockets of interactivity scattered through otherwise static pages, React was never the tool the job needed. It was the tool that happened to be there because that’s what the ecosystem defaults to.
Alpine.js and React are the two compared here because that’s the actual before-and-after of this migration. Vue’s minimal build, Preact, Solid, plain web components, and htmx on its own occupy similar territory, and any of them could answer the same underlying question just as well.
TL;DR
- For content-driven, mostly server-rendered sites with small islands of interactivity, Alpine.js is a pragmatic choice, one of several reasonable lightweight options. For apps built around complex, deeply nested client state, a component framework (React, Vue, Svelte, Solid) is the right category of tool.
- Alpine.js ships around 7kB gzipped from a CDN. React plus React DOM alone runs 28-45kB gzipped before a single line of app code, and a typical real-world React bundle with routing and state management commonly lands closer to 130kB.
- Alpine’s own maintainers confirm Alpine and htmx are commonly paired in production, and there’s an official htmx integration built specifically to keep Alpine’s local state intact across page swaps.
- Signals, the TC39 proposal for a shared reactive primitive across frameworks, sits at Stage 1 as of this writing. It’s a genuinely interesting direction, but for a different problem than the one this piece is about.
- I’ve built and maintain two open-source developer tools around Alpine.js specifically, evidence of ongoing commitment to the stack, not just a one-off swap.
What React is for, and what most sites don’t need
React exists to solve a real problem: complex, deeply interdependent client-side state, where a change in one place needs to ripple through a tree of components efficiently, and a virtual DOM diffing algorithm earns its keep. Dashboards, editors, anything with genuinely complicated, interactive state, React is a reasonable default there, and so are Vue, Svelte, and Solid, each with its own tradeoffs.
Most content sites are not that. A farm directory with search, filters, and listing pages doesn’t have complex client state. It has a handful of small, independent interactive behaviors, a mobile nav that opens and closes, a modal, a form that validates before submitting, scattered across pages that are otherwise just server-rendered HTML. Reaching for a full component framework with its own rendering engine to manage a dropdown’s open/closed state means using a tool built for a much harder problem than the one actually in front of it.
What the bundle size difference actually looks like
Numbers here vary depending on exactly what’s being measured (CDN build vs. npm-bundled, core library vs. real app bundle), so each figure below links to its source rather than being quoted as a single round number:
| Minified + gzipped | |
|---|---|
| Alpine.js (CDN build) | ~7.1kB |
| React + React DOM (core libraries only) | ~28-45kB |
| Typical real-world React app bundle (with routing, state management) | ~130kB |
Alpine’s npm-installed build can run somewhat larger than the CDN figure due to tree-shaking differences. Even accounting for that, the gap to a full React app bundle is roughly an order of magnitude, not a rounding error. On a mid-range mobile device, that difference in JavaScript that has to be downloaded, parsed, and executed before a page becomes interactive is a real, measurable chunk of load time.
This isn’t just an opinion about one migration
Alpine.js runs in production in the current Express app, handling the small, scattered interactive elements this piece is actually about, not as a one-off experiment. The two developer tools built around it get real use too: Alpine.js Tools for VS Code picked up Liquid template support after a Shopify theme developer requested it directly, calling the extension “a phenomenal package” and noting that Alpine has become the go-to choice for Vite-powered Shopify theme development, commonly used together with Liquid.
Alpine.js is also commonly paired with htmx in similar architectures elsewhere, though htmx isn’t part of this stack. htmx handles server requests and HTML swapping, Alpine.js handles small pockets of local UI state. Alpine’s own maintainers confirm the pairing directly in the project’s GitHub discussions, and there’s an official htmx plugin built specifically around Alpine.morph, so Alpine’s local state survives a page swap instead of getting wiped out and rebuilt. Carson Gross, htmx’s creator, has written at length about when a hypermedia-driven, mostly server-rendered architecture is the right fit: text and image-heavy UIs, CRUD applications, nested UIs with well-defined regions, a description that covers a large share of content sites.
React remains a solid choice for plenty of sites. The narrower point here holds regardless: a real site doesn’t need a component framework by default, backed by production use and by the people building the adjacent tooling.
Where Signals fit, and where they don’t
The TC39 Signals proposal is a standards effort to give JavaScript a shared reactive primitive, a common Signal.State/Signal.Computed model that Angular, Preact, Solid, Vue, Svelte, and others could all build their reactivity on top of, rather than each framework reinventing its own. As of this writing it sits at Stage 1, the earliest formal stage in the TC39 process, and the proposal’s own authors describe their plan as deliberately conservative: extensive prototyping and multi-framework integration before pushing for advancement.
That’s a genuinely interesting direction regardless of how far along it is, but it’s solving a different problem than the one this piece is about. Signals target complex, highly reactive client state, the same problem space React’s component model addresses, just with a different underlying primitive. They aren’t a competing answer to whether a content page needs a component framework at all. I haven’t used Signals in production and won’t claim otherwise here; it’s worth watching for anyone building genuinely complex, highly reactive client applications, not the kind of site this piece is about.
What this looked like in practice
Concretely, the swap meant replacing React components that existed purely to manage small, local UI state with x-datablocks directly on the HTML elements they controlled. A mobile nav toggle went from a component with useState and conditional rendering to a div with x-data="{ open: false }" and an x-show="open" on the menu. A search filter dropdown went from managing its own state and re-render cycle to a handful of Alpine directives sitting directly in the EJS template next to the markup they affect. No component boundary to manage, no props to pass down, no separate build step to compile any of it, the behavior lives in the same file as the HTML it controls.
When a component framework is still the right call
If a project has genuinely complex, deeply interdependent client state, a real-time collaborative editor, a dashboard with dozens of interacting widgets, anything where a virtual DOM’s diffing (or an equivalent reactive rendering model) actually earns its cost, a component framework remains the right category of tool. Which one (React, Vue, Svelte, Solid) is a separate decision with its own tradeoffs. Rewriting that kind of app in Alpine.js would be fighting the tool rather than using it well.
Tools built around this
I maintain two open-source developer tools built specifically around Alpine.js, because it’s the stack reached for daily: Alpine.js Tools for VS Code, which adds IntelliSense, hover documentation, and syntax highlighting for Alpine directives across HTML, EJS, PHP, Twig, Nunjucks, and Blade, and alpinejs.nvim for the same experience in Neovim, with Tree-sitter-based highlighting and completion. That kind of ongoing tooling investment only makes sense for a stack used daily.
Frequently asked questions
Does the bundle size gap still matter if React is code-split?
Code splitting reduces what loads on any single route, but it doesn’t remove the baseline. A React app still ships React and React DOM’s core runtime, that 28-45kB floor, on every page that needs any interactivity at all, before any route-specific chunks are added on top. Alpine’s entire footprint is smaller than that baseline alone, with no splitting required because there’s nothing large enough to need splitting.
Should you wait for Signals instead of adopting Alpine.js now?
Not for this kind of site. Signals target complex, highly reactive client state, and at Stage 1 they’re likely years from landing as a native, cross-browser primitive usable without a polyfill or a framework wrapper. Small, scattered interactivity on a content site isn’t the problem Signals are trying to solve, so there’s nothing to wait for.
What’s the actual downside of choosing Alpine.js over React?
A smaller plugin and component ecosystem, no equivalent to React’s TypeScript-driven component typing, and less mature tooling for things like automated testing or state inspection in dev tools. None of that tends to matter much for a handful of dropdowns and toggles, but it’s a real tradeoff, not a free upgrade.