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 put x-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 to window.
    • Drive modal visibility with state (x-show) rather than classList.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.

    Share this article: