Week 1 — 11ty + Alpine.js: Reactive UI for SEO-First Sites
How I kept HTML and JS in their own lanes—using Alpine.js with 11ty for clean, API-driven reactivity without a heavy framework.
Published on Aug 9, 2025
Table of Contents
TL;DR
- Small, SEO-first sites: 11ty + TailwindCSS fits perfectly and keeps things fast.
- Reactivity without a SPA: Alpine.js gives me live, API-driven UI while templates stay declarative.
- Cleaner code: fewer imperative DOM updates, fewer lines, easier to reason about.
Project context: Crypto IBC dashboard
I partnered with an investor to build a dashboard that tracks crypto investments for an IBC policy—positions by coin, staking income plus price movement, and weekly performance reports. The stack is static-site friendly (S3 + CloudFront), so I wanted UI reactivity without turning the site into a SPA.
The pain: imperative DOM updates
11ty is fantastic for build-time data. But for live API data, I found myself doing imperative DOM work from JavaScript—querySelector
, innerHTML
, classList
—which spreads UI logic between templates and scripts and makes components brittle.
The pattern that clicked
I keep Alpine logic in JS modules and register everything on alpine:init
(stores + components). Templates stay declarative with x-*
bindings. Result: HTML describes state; JS owns data + actions.
Before: imperative updates
<div id="price"></div>
<script>
fetch('/api/price')
.then(r => r.json())
.then(({ price }) => {
document.querySelector('#price').textContent = price.toFixed(2);
});
</script>
After: Alpine.js (logic in JS, templates declarative)
/assets/scripts/app.js
document.addEventListener('alpine:init', () => {
// Shared store for prices
Alpine.store('prices', {
value: null,
async load() {
const { price } = await (await fetch('/api/price')).json();
this.value = price;
}
});
// Page component
Alpine.data('dashboard', () => ({
loading: true,
async init() {
await this.$store.prices.load();
this.loading = false;
}
}));
});
/src/case-studies/crypto-ibc-dashboard/index.njk (snippet)
<section x-data="dashboard()" x-init="init()" class="max-w-4xl mx-auto my-8 space-y-6">
<p x-show="loading">Loading…</p>
<p x-show="!loading">$<span x-text="$store.prices.value?.toFixed(2)"></span></p>
</section>
Why this works well for 11ty
- SEO-friendly: No client router to fight; fast TTFB + simple meta.
- Tiny footprint: Alpine is lightweight and progressive—enhance only where needed.
- Separation of concerns: templates describe state; JS manages data & actions.
- GA4 stays simple: no virtual pageviews; track key interactions with tiny helpers.
Gotchas & tips
- Add a global cloak to avoid flash of uninitialized content:
[x-cloak]{ display:none !important; }
, then putx-cloak
on elements that should stay hidden until Alpine initializes. - Use
Alpine.store(...)
for shared state like schedules, assets, and enums (status labels), instead of attaching globals towindow
. - Drive modal visibility with state (
x-show
) rather thanclassList.toggle
. - Keep server-rendered defaults for SEO, then let Alpine enhance after load.
What’s next
- Kraken import and UTC ↔ local date handling for event timelines.
- Weekly rollups: invested vs. rewards vs. unrealized P/L.
- First weekly performance email draft.
Your turn
If you build with 11ty, have you paired it with Alpine.js this way (logic in JS modules, templates as bindings)? I’d love to hear patterns you like.