← All guides

Mock a REST API for Alpine.js β€” fetch, x-for, live search and real pagination with zero build steps

Alpine's whole pitch is no build step: one <script> tag, sprinkle behaviour on your HTML, done. Which makes the usual mock-data answers feel backwards:

A hosted mock API keeps the deal Alpine offered: one HTML file, zero npm on either side. Alpine comes from a CDN, the data comes from a real https URL with CORS open β€” so the same file works from file://, a static host, or a snippet playground. Every snippet below was run verbatim in a real Chrome against the live endpoints on this page before publishing; the outputs shown are from those runs.

1. Try it in 10 seconds (no signup)

A shared, self-resetting demo project is live right now:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"

That URL is the API in the snippets below. When you want your own schema, it's one curl or one click β€” covered in Β§7.

⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard β€” real URL, data browser already open, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.

2. Fetch and render: x-data, x-init, x-for

Alpine evaluates x-init with an async evaluator, so you can await fetch(...) right in the attribute:

<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>

<div x-data="{ products: [] }"
     x-init="products = await (await fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3')).json()">
  <ul>
    <template x-for="p in products" :key="p.id">
      <li x-text="`${p.name} β€” $${p.price}`"></li>
    </template>
  </ul>
</div>

Rendered output from the verification run:

Stone Place Week β€” $219.34
Book Group Mountain β€” $830.47
Right Mountain Door β€” $906.58

3. Loading and error states (and the try/catch gotcha)

Here's the Alpine gotcha that bites everyone once: x-init takes an expression, not a statement block. This does not work β€”

<!-- βœ— "Alpine Expression Error: Unexpected token 'try'" -->
<div x-data="{ state: 'loading' }"
     x-init="try { ... } catch (e) { state = 'error' }">

β€” because Alpine wraps the expression in a return (...). The idiomatic fix: put the logic in a method on x-data and call it from x-init. This version drives a real spinner and a real error branch:

<div x-data="{ state: 'loading', products: [],
      async load() {
        try {
          const r = await fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=5&mock_delay=2000');
          if (!r.ok) throw r.status;
          this.products = await r.json(); this.state = 'ready';
        } catch (e) { this.state = 'error' }
      } }"
     x-init="load()">
  <p x-show="state === 'loading'">loading…</p>
  <p x-show="state === 'error'">couldn’t load products β€” retry?</p>
  <ul x-show="state === 'ready'">
    <template x-for="p in products" :key="p.id"><li x-text="p.name"></li></template>
  </ul>
</div>

The two query params are the point:

There's also ?mock_seq=500,500,200 (deterministic fail-fail-succeed for retry logic), ?mock_chaos=0.3 (random failures) and ?mock_jitter (random latency) β€” the simulation-params guide covers the full set.

x-effect re-runs whenever a value it reads changes, and it accepts await β€” combined with a debounced x-model, that's a complete typeahead with no event wiring at all:

<div x-data="{ q: '', results: [] }"
     x-effect="results = q.length
       ? await (await fetch(`https://mockbird.mockbird.workers.dev/m/demo/products?q=${encodeURIComponent(q)}&limit=5`)).json()
       : []">
  <input x-model.debounce.300ms="q" placeholder="search products…">
  <ul>
    <template x-for="r in results" :key="r.id"><li x-text="r.name"></li></template>
  </ul>
</div>

Typing mountain in the verification run returned 5 matches after the debounce β€” ?q= is a substring search across every field, so it behaves like the search box users expect. For a single-field match use ?name_like=mountain; range and inequality filters (?price_gte=100, ?stock_ne=0) compose the same way.

5. A form that really saves: writes persist

Unlike JSONPlaceholder-style fake APIs, POST here actually stores the record β€” your submit path gets a real 201 and the item is really in the list afterwards (teammates hitting the same URL see it too):

<form x-data="{ name: '', saved: null }"
      @submit.prevent="saved = await (await fetch('https://mockbird.mockbird.workers.dev/m/demo/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, price: 4.99, category: 'toys' })
      })).json()">
  <input x-model="name" placeholder="product name">
  <button>Add</button>
  <p x-show="saved" x-text="saved && `saved #${saved.id} β€” ${saved.name} ($${saved.price})`"></p>
</form>

Verification run: submitting rendered saved #33 β€” Alpine Test Widget ($4.99), a follow-up GET returned the record, and a DELETE cleaned it up. PUT and PATCH work the same way.

6. Pagination from a real X-Total-Count header

"Page 2 of 7" needs the total, and the total comes from a response header β€” which is exactly the kind of thing a hardcoded array can't teach you to read. _page/_limit (json-server style; page/limit work too) plus X-Total-Count:

<div x-data="{ page: 1, total: 0, products: [],
      async load() {
        const r = await fetch(`https://mockbird.mockbird.workers.dev/m/demo/products?_page=${this.page}&_limit=5`);
        this.total = +r.headers.get('X-Total-Count');
        this.products = await r.json();
      } }"
     x-init="load()">
  <template x-for="p in products" :key="p.id"><div x-text="p.name"></div></template>
  <button @click="page--; load()" :disabled="page === 1">β€Ή prev</button>
  <span x-text="`page ${page} of ${Math.ceil(total / 5)}`"></span>
  <button @click="page++; load()" :disabled="page >= Math.ceil(total / 5)">next β€Ί</button>
</div>

Verification run: renders page 1 of 7 with prev correctly disabled; clicking next swaps in the next 5 records and the counter reads page 2 of 7. (X-Total-Count is in the CORS exposed headers, so r.headers.get() works cross-origin β€” another thing localhost setups never make you check.)

7. Your own schema in one curl (or one click)

The demo is shared and resets daily. Your own project β€” own resources, own fields, 30 seeded realistic records β€” is:

curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H "content-type: application/json" -d '{"preset":"ecommerce"}'
# β†’ {"id":"YOUR_ID", "adminKey":"...", ...}   β€” mock URL: /m/YOUR_ID/products

…or skip the terminal: one click creates it in the dashboard (presets: blog, ecommerce, saas, or define fields yourself). Also useful with Alpine:

8. Honest comparison

Array in x-dataLocal json-serverMSWMockbird
Matches Alpine's no-build ethosβœ”βœ— (Node per machine)βœ— (npm + service worker)βœ” (a URL)
Exercises real fetch / CORS / headersβœ—partly (no CORS lessons on localhost)βœ— (intercepted in-process)βœ”
Works when the HTML leaves your machineβœ” (but still fake)βœ—βœ” in tests onlyβœ”
Inject latency / errors / rate limitsβœ—middleware to writeβœ” (in code)one query param
Offline / zero latencyβœ”βœ”βœ”βœ— β€” it's a real network call

Be clear-eyed: MSW is the right tool for automated tests of a bundled app, and a real backend is the right tool for production. A hosted mock is for the space between β€” building tonight, sharing a working page tomorrow, and making the sad paths (slow, down, empty) testable by URL.

Related: mock API for htmx (Alpine's usual partner β€” fragment endpoints), testing loading and error states, mock any custom endpoint, and free mock-API tools compared.

Verification: every snippet on this page was run verbatim in a real Chrome (via a plain HTML file, Alpine 3 from the jsDelivr CDN) against production on 8 Sep 2026 β€” the rendered outputs quoted are from those runs, including the try/catch expression error, the 2-second spinner, the 500 error branch, the debounced search, the persisted POST (record deleted afterwards), and the page-2-of-7 pagination click. If a snippet doesn't work on your page, that's a bug: tell us.