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:
x-data β instant, but your fetch code, loading spinner and error branch never actually run;localhost:3000 is unreachable;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.
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.
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
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:
?mock_delay=2000 holds the response for 2 s β in the verification run the page really shows loadingβ¦ for 2 seconds, then the list. No more "the spinner works, probably".?mock_status=500 (any code: 404, 429, 503β¦) makes the same snippet render couldnβt load products β retry? β your error branch runs before a user ever sees it by accident. Both verified.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.
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.
"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.)
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:
GET /config/theme for feature flags your x-data reads at boot);Authorization header flow for testing gated UI states;console.log archaeology.| Array in x-data | Local json-server | MSW | Mockbird | |
|---|---|---|---|---|
| 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.