Documentation / Alpine.js + JSON

Alpine.js + JSON

Keep the page server-rendered and give one interactive region a JSON contract. Outpost uses Alpine for an outdoor trip finder.

The browser owns the filter state

Open Outpost. The marketing page is rendered through c.View("outpost.tmpl", demoTrips). HyperChi exposes this typed slice as .Result inside the template. On form submission, the trip finder requests /examples/alpine/trips?terrain=all, coast, mountain, or forest. Go returns a filtered collection; Alpine updates the cards.

Alpine is a browser library, not a Go template engine. The server uses html/template for the page shell and encoding/json for trip data. The example includes a pinned local Alpine runtime, so page interaction does not depend on a CDN request.

An explicit JSON contract

{
  "trips": [
    {
      "name": "Coastal weekend",
      "terrain": "coast",
      "days": 3,
      "price": 240,
      "description": "A sample coastal walking trip."
    }
  ],
  "count": 1
}

This illustrative response shows the schema; inspect the endpoint for current fixture values. trips is an array, days and price are numbers, and count is the number of matching trips. Values are display fixtures rather than availability or checkout quotes.

The initial page includes all four trips as server-rendered cards; it does not fetch on initialization. static/js/outpost.js registers the tripFinder Alpine component, ignores overlapping submissions, aborts requests after ten seconds, and exposes an error with a retry button. After a successful request, the Alpine-rendered cards replace the initial collection.

A small Alpine consumer

This minimal consumer shows loading and failure handling. Load Alpine with defer, and place the component definition before Alpine initializes:

<script>
function tripFinder() {
  return {
    trips: [], loading: false, error: '',
    async load(terrain = 'all') {
      this.loading = true;
      this.error = '';
      try {
        const response = await fetch(
          '/examples/alpine/trips?terrain=' + encodeURIComponent(terrain)
        );
        if (!response.ok) throw new Error('Unable to load trips');
        const data = await response.json();
        this.trips = data.trips;
      } catch (error) {
        this.error = 'Trips could not load. Please try again.';
      } finally {
        this.loading = false;
      }
    }
  };
}
</script>
<section x-data="tripFinder()" x-init="load()">
  <button @click="load('coast')" :disabled="loading">Coast</button>
  <p x-show="loading" role="status">Loading trips…</p>
  <p x-text="error" role="alert"></p>
  <template x-for="trip in trips" :key="trip.name">
    <article><h3 x-text="trip.name"></h3></article>
  </template>
</section>

Disabling the control during loading prevents overlapping requests in this smaller recipe. For controls that remain enabled, cancel earlier fetches or ignore stale responses so a slower old request cannot replace the newest selection.

Render text as text

Use x-text for API strings so they become text content. Avoid x-html for untrusted data. Go’s JSON encoder handles JSON syntax; that alone does not make a value safe to insert as HTML. Alpine’s x-data guide explains state scopes, and its x-text guide covers text binding.

Without JavaScript, the marketing content and the full four-trip collection still render. The interactive filtering needs Alpine and the JSON endpoint. The demo makes no booking request and stores no visitor information.

Use HTML when it is the simpler contract

If the browser only needs to replace a section, Jet + htmx or templ + htmx can return finished HTML. JSON is useful when the browser needs the individual fields for local state or other JavaScript behavior.