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 deferred script tag.
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 is what the ecosystem defaults to.
Alpine.js and React are compared here because they are 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.
Rundown
- Alpine’s production CDN build measures 16.7kB gzipped, or 15.1kB with brotli, from a 46.3kB raw file. React plus React DOM measure about 47kB gzipped before a line of application code.
- That is a difference of about 2.8x on the client runtime, not the order of magnitude often claimed. The numbers below are measured from the shipped files, with the method included so they can be checked.
- The median page shipped 558kB of JavaScript on mobile in 2024, per the HTTP Archive. Framework choice is one input into that number, and rarely the largest one.
- While checking these numbers I found the site serving Alpine uncompressed, at 46.3kB instead of 16.7kB, along with 145kB of uncompressed CSS. Enabling compression saved 154kB, five times the entire Alpine-versus-React gap.
- Alpine is self-hosted from
node_modulesrather than loaded from a public CDN, cached immutably for a year, and loaded withdefer. - Two sites run it in production here: 24 component scopes and roughly 215 directives on one, 12 scopes and 476 directives on the other, including 224
x-modelbindings across admin forms. - Signals, the TC39 proposal for a shared reactive primitive, sits at Stage 1 with a deliberately slow advancement plan. It targets a different problem than this one.
What React solves, and what a content site has instead
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 diffing algorithm earns its keep. Dashboards, editors, collaborative tools. React is a reasonable default there, and so are Vue, Svelte, and Solid.
Most content sites are not that. A farm directory with search, filters, and listing pages does not have complex client state. It has a handful of small, independent behaviors, a mobile nav that opens and closes, a modal, a form that validates before submitting, scattered across pages that are otherwise server-rendered HTML. Reaching for a component framework with its own rendering engine to manage a dropdown’s open state applies a tool built for a much harder problem than the one in front of it.
Measuring the difference
Published bundle-size figures for these libraries vary wildly, because different sources measure different artifacts: the npm package entry point, a tree-shaken application build, or the standalone file you put in a script tag. Those are not the same thing, and comparing across them produces numbers that are off by more than a factor of two.
What follows measures the actual files a browser downloads, so the method can be repeated:
# Alpine's production CDN build
curl -sL https://cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js | gzip -9 | wc -c
# React and React DOM, production UMD builds
curl -sL https://unpkg.com/react@18/umd/react.production.min.js | gzip -9 | wc -c
curl -sL https://unpkg.com/react-dom@18/umd/react-dom.production.min.js | gzip -9 | wc -c
| Artifact | Raw | Gzip | Brotli |
|---|---|---|---|
Alpine.js 3.15.12, cdn.min.js | 46,346 B | 16,694 B (16.7kB) | 15,120 B (15.1kB) |
React 18, react.production.min.js | 10,751 B | 4,263 B (4.3kB) | n/a |
React DOM 18, react-dom.production.min.js | 131,835 B | 42,884 B (42.9kB) | n/a |
| React + React DOM combined | 142,586 B | 47,147 B (47.1kB) | n/a |
Two honest caveats. React 19 removed the UMD builds, so the React figures come from 18, the last version that shipped a standalone file comparable to Alpine’s. And Alpine’s npm entry point measures larger than its CDN build, 25.3kB gzipped for module.esm.js, because it is unminified and expects a bundler to handle that.
So the gap on the client runtime is roughly 16.7kB against 47kB, a factor of about three. That is real. It is not the ten-to-one difference that gets quoted when a 7kB figure for Alpine is set against a 130kB figure for a full application bundle, which compares a library against an entire app.
What the median page ships
A framework runtime is a small part of the picture. The HTTP Archive’s 2024 Web Almanac found the median page shipping 558kB of JavaScript on mobile and 613kB on desktop, a 14% year-over-year rise. Against that baseline, the 30kB difference between these two runtimes is about 5% of a median page’s JavaScript.
The same report puts React on roughly 10% of pages, while jQuery still appears on 74%. The web’s JavaScript weight problem is mostly not a React problem, and swapping React for Alpine does not by itself solve it.
Which is the honest frame for this entire comparison. Choosing Alpine over React on a content site is a reasonable decision that removes a build step and a rendering engine you were not using. Treating it as a performance strategy on its own overstates it.
The bytes that mattered more than the framework
While checking the numbers above, I measured what this site was serving, and found it shipping Alpine’s 46.3kB raw file rather than the 16.7kB compressed one.
Nginx compresses text/html by default and nothing else. Everything Express served, JavaScript, CSS, JSON, was going out uncompressed. The stylesheet alone was 145kB where it should have been 26kB.
The fix was one line of middleware, above the static handler and the routers:
import compression from 'compression';
app.use(compression());
Plus a matching gzip_types in nginx for the paths it serves directly rather than proxying:
gzip_types text/plain text/css text/javascript application/javascript
application/json application/xml image/svg+xml font/woff2;
| Asset | Before | After |
|---|---|---|
main.css | 145,256 B | 25,909 B |
alpinejs/cdn.min.js | 46,346 B | 16,685 B |
main.js | 8,400 B | 2,869 B |
154kB saved on first load, from a configuration default. That is five times the entire difference between Alpine and React, and it sat there the whole time the site was serving an article arguing that small JavaScript payloads matter.
The lesson generalizes past the embarrassment. Compression, caching headers, and image weight are usually worth more than framework choice, they are cheaper to fix, and they are easy to leave misconfigured for years because nothing visibly breaks. Framework selection is the interesting argument. Transfer configuration is the one that moves the numbers.
How Alpine is wired up
Alpine is a dependency in package.json, not a script tag pointing at someone else’s server:
app.use('/vendor/alpinejs', express.static(
join(__dirname, 'node_modules', 'alpinejs', 'dist'),
{ maxAge: '1y', immutable: true }
));
<script defer src="/vendor/alpinejs/cdn.min.js"></script>
Self-hosting the file npm already installed costs nothing and avoids a third-party DNS lookup, TLS handshake, and availability dependency on every first visit. The immutable directive tells browsers never to revalidate within the year, since the URL changes when the version does. defer keeps it off the critical rendering path.
The version pinned is alpinejs@^3.15.12. The file it serves is the same cdn.min.js measured above, which is why the 16.7kB figure is the one that matters here rather than the npm entry point’s 25.3kB.
What replaced the React components
The swap meant replacing components that existed purely to manage small, local UI state with x-data blocks on the elements they controlled. A mobile nav toggle went from useState plus conditional rendering to this:
<div x-data="{ open: false }">
<button @click="open = !open" :aria-expanded="open">Menu</button>
<nav x-show="open" x-transition>…</nav>
</div>
No component boundary, no props threading, no build step. The behavior lives in the same file as the markup it controls.
Two production sites run this now, and the usage is heavier than “a few dropdowns” suggests:
| Component scopes | Directives | Heaviest use | |
|---|---|---|---|
| This site | 24 | ~215 | 36 x-show, 28 @click, 28 x-text |
| The directory site | 12 | 476 | 224 x-model, 87 x-show, 63 x-text |
That second row is the interesting one. 224 two-way bindings across admin forms is exactly the kind of workload people assume requires a component framework, and it is handled by attributes on the inputs, with the server rendering the form and Alpine holding the transient state.
What 62 useState calls became
The clearest before-and-after in this migration is the listing edit form. In the React version it was a single client component of 1,006 lines with 62 useState declarations, one per field, each paired with an onChange handler threading values back:
const [name, setName] = useState(listing.name || '')
const [street, setStreet] = useState(listing.location?.street || '')
const [city, setCity] = useState(listing.location?.city || '')
const [state, setState] = useState(listing.location?.state || '')
// …58 more
Every field needed a declaration, a setter, a controlled value, and an onChange. The form’s markup lived in JSX in the same file, so the component carried both the state machinery and the presentation.
The Alpine replacement puts the initial values in the server-rendered HTML, where they already were, and binds each input with one attribute:
<input type="text" name="name" x-model="form.name" value="<%= listing.name %>">
The equivalent template runs 432 lines including all the markup, against 1,006 lines for the React component alone. Across the whole set, fifteen React form components totalled 6,084 lines.
The saving is not cleverness, it is the removal of a layer. The server already knows the field values and already renders the form. React’s version re-declared that state on the client so it could re-render markup the server had produced moments earlier. Alpine skips the re-declaration and attaches behavior to the markup that already exists.
That is the whole argument in miniature. On a page whose content originates on the server, a client-side component tree is a second copy of something you already have.
What removing the build step buys
The migration deleted more than a dependency. The React version needed a bundler, a JSX transform, a TypeScript compiler pass, and a dev server that rebuilt on save. The Alpine version needs a script tag.
Practical consequences, in rough order of how often they come up:
- Edit and refresh. Changing a dropdown’s behavior means editing the template and reloading. No rebuild, no HMR to get confused, no waiting.
- Nothing to keep current. No bundler config drifting out of date, no build-time dependency chain to audit or upgrade.
- The deployed file is the authored file. Debugging in production shows the same attributes written in the template, with no source map indirection.
- Templates stay portable. The same directives work in EJS, Blade, Twig, Jinja, or Liquid, because they are HTML attributes rather than a compile target.
The tradeoff is that expressions in attributes get no type checking and no compile-time errors. A typo in x-model="form.nmae" fails silently at runtime, where TypeScript would have caught it. That is a real cost, and it is the strongest argument for a component framework on a form-heavy application.
Auditing your own site
Before comparing frameworks, check what a site transfers, because configuration defaults tend to dominate. Three checks, in order of how much they typically find.
First, confirm compression is on for every asset type, not just HTML:
for p in / /css/main.css /js/main.js; do
echo -n "$p → "
curl -sI -H "Accept-Encoding: gzip, br" https://example.com$p \
| grep -i content-encoding || echo "NOT COMPRESSED"
done
An asset with no content-encoding is shipping two to three times its necessary size. That single check is what turned up the 154kB on this site.
Second, measure the JavaScript a page loads in practice, rather than what the bundle report claims:
curl -s https://example.com | grep -oE '<script[^>]+src="[^"]+"' | wc -l
Then total the transfer sizes in the browser’s network panel with the cache disabled. Compare against the 558kB mobile median rather than against zero.
Third, check cache headers on static assets. max-age=0 on a stylesheet means every repeat visitor re-downloads it, which costs more over a month than any framework decision. Long max-age with immutable is correct, provided the URL changes when the file does.
Framework choice is the last of these to matter, and the most expensive to change.
Where htmx fits
Alpine pairs with htmx in similar architectures, though htmx is not part of this stack. htmx handles server requests and HTML swapping; Alpine handles local UI state.
The pairing is common enough that an official htmx extension exists for it. The alpine-morph extension, maintained in htmx’s own extensions repository, lets htmx use Alpine.morph as its swap mechanism, reconciling the existing DOM rather than replacing it, so Alpine’s reactive state survives a swap instead of being destroyed and rebuilt.
In Alpine’s discussion tracker, a frequent contributor to the project, with over 150 comments in the repository, describes the two as solving different problems and notes running both together commercially: “I have large clients using both together.” That is a practitioner’s report rather than an official project position, which is the right weight to give it.
Carson Gross, htmx’s creator, has written at length about where this architecture fits. His good-fit list is text and image heavy UIs, CRUD-shaped applications, nested UIs where updates happen within well-defined blocks, and cases needing deep links and good first-render performance. He is equally direct about the poor fits: many dynamic interdependencies, offline requirements, and UI state that updates extremely frequently. A content directory sits squarely in the first list.
Where Signals fit
The TC39 Signals proposal would give JavaScript a shared reactive primitive, a common Signal.State and Signal.Computed model that Angular, Preact, Solid, Vue, and others could build on instead of each reinventing its own.
It sits at Stage 1, and its authors are deliberate about not rushing: their stated plan is significant early prototyping, including integration into several frameworks, before advancing beyond that stage. The intent is to avoid standardizing an API that frameworks decline to adopt.
Signals target complex, highly reactive client state, the same problem space React’s component model addresses, with a different underlying primitive. They are not a competing answer to whether a content page needs a component framework. Worth watching for anyone building highly reactive applications; not something to wait for before putting an x-data on a dropdown.
When a component framework is the right call
If a project has deeply interdependent client state, a real-time collaborative editor, a dashboard with dozens of interacting widgets, anything where a reactive rendering model earns its cost, a component framework remains the right category of tool. Which one is a separate decision with its own tradeoffs. Rewriting that kind of application in Alpine would be fighting the tool.
The dividing line is not site size or traffic. It is whether state is shared and interdependent, or local and scattered. A page with fifty independent toggles is easy in Alpine. A page with five widgets that all need to agree about the same object is where a component framework starts paying for itself.
What you give up
A smaller plugin ecosystem, no equivalent to React’s TypeScript-driven component typing, and less mature tooling for automated testing or state inspection in dev tools. Expressions live in HTML attributes, which means no type checking and limited editor support without a dedicated extension.
There is also a real ceiling. Alpine expressions are meant to be short. Once a component’s logic outgrows an attribute and moves into a x-data="componentName()" function, the ergonomic advantage narrows, and past a certain complexity a component framework is the better tool. Recognizing that boundary matters more than picking a side.
For a handful of dropdowns and toggles none of that bites. It is a real tradeoff rather than a free upgrade.
Tools built around this
I maintain two open-source developer tools for Alpine, because it is 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 in Neovim, with Tree-sitter-based highlighting and completion.
The VS Code extension picked up Liquid template support after a Shopify theme developer requested it directly, calling it “a phenomenal package” and noting that Alpine has become a common choice for Vite-powered Shopify theme development.
Related reading
The migration that removed React from this stack was driven by problems unrelated to bundle size: unbounded memory growth from caching high-cardinality dynamic routes, and social embeds breaking on client-side navigation. Alpine replacing React was a consequence of that move rather than its cause.