← All guides

Mock a REST API for your SolidJS app in 60 seconds

Solid gives you createResource, Suspense and ErrorBoundary β€” a complete async-UI toolkit that's begging for an API that can actually exercise it. A hard-coded array can't show you your loading skeleton. It can't throw a 500 into your ErrorBoundary, and it can't tell you whether your Retry button really retries (spoiler from our test run: it probably doesn't). This guide points createResource at a hosted mock API with realistic data whose latency, errors and chaos you control from the URL bar.

Every snippet below was run before publishing, in a fresh npm create vite project (--template solid, Vite 8.2.0, solid-js 1.9.14), driven in a real browser against the live API. The three gotchas below were each reproduced for real in that run, not paraphrased from docs.

1. Get an API (10 seconds)

Click "try it" on the Mockbird landing page, or from a terminal:

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -d '{"name":"solid-demo","preset":"ecommerce"}'
# β†’ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123"}

The ecommerce preset seeds products, orders, customers and reviews with realistic data. No signup, CORS on by default. Put the base URL in .env.local:

# .env.local
VITE_API_BASE=https://mockbird.mockbird.workers.dev/m/abc123

(No terminal handy? Every example below also works against the public demo project: https://mockbird.mockbird.workers.dev/m/demo.)

2. Generate the TypeScript types (don't write them)

curl -o src/types.ts https://mockbird.mockbird.workers.dev/m/abc123/types.ts

Interfaces for every resource, generated from the live schema β€” or ?format=zod for Zod schemas if you validate at the boundary.

3. createResource + Suspense + For

import { createSignal, createResource, Suspense, ErrorBoundary, For } from "solid-js";

const BASE = import.meta.env.VITE_API_BASE;

async function fetchProducts(q) {
  const url = new URL(`${BASE}/products`);
  if (q) url.searchParams.set("q", q);
  url.searchParams.set("limit", "10");
  const res = await fetch(url);
  if (!res.ok) throw new Error(`API ${res.status}`);   // fetch does NOT throw on 500
  return res.json();
}

export default function App() {
  const [query, setQuery] = createSignal("");
  const [products, { refetch }] = createResource(query, fetchProducts);

  return (
    <main>
      <input placeholder="search…" value={query()}
             onInput={(e) => setQuery(e.currentTarget.value)} />
      <ErrorBoundary fallback={(err, reset) => (
        <p>Failed to load: {err.message}{" "}
          <button onClick={() => { refetch(); reset(); }}>Retry</button>
        </p>
      )}>
        <Suspense fallback={<p>Loading products…</p>}>
          <ul>
            <For each={products.latest}>
              {(p) => <li>{p.name} β€” ${p.price}</li>}
            </For>
          </ul>
        </Suspense>
      </ErrorBoundary>
    </main>
  );
}

That's the whole app. The search box is reactive for free: query is the resource's source, so every keystroke re-runs the fetch with the new value β€” in our run, typing cloud live-filtered 10 products down to 7 matches with zero extra wiring. Three lines in that snippet earn their place because we watched each one fail without it; the next three sections are those gotchas.

4. Gotcha #1: a falsy source silently never fetches

createResource skips fetching while its source is false, null or undefined β€” that's the documented way to defer a fetch. The trap is doing it by accident:

// looks harmless, breaks the initial load:
const [products] = createResource(() => query() || undefined, fetchProducts);

When we ran this, the page rendered nothing β€” no list, no loading fallback, no error, because the initial "" became undefined and the fetch never fired. It started working the moment you typed a character, which makes it a confusing bug to chase. An empty string is a perfectly good source value (our verified snippet passes query directly); reserve the falsy-skip for fetches that genuinely depend on something existing (() => user()?.id).

5. See your loading state β€” for real

Add ?mock_delay=3000 to the request (or bake it into the URL in .env.local) and the API holds the response for 3 seconds:

https://mockbird.mockbird.workers.dev/m/abc123/products?mock_delay=3000

In our browser run the Suspense fallback ("Loading products…") stayed up for the full 3 s, then the list swapped in. This is how you check the skeleton actually renders, doesn't jump layout, and disappears β€” instead of shipping a spinner you've never seen.

6. See your error state β€” and gotcha #2: Retry that doesn't retry

Two things have to be true before your ErrorBoundary ever fires. First: fetch resolves successfully on a 500 β€” if you don't check res.ok and throw, an error response just becomes garbage data. Second, the one we reproduced: the boundary's reset callback only re-renders the children. The resource is still in its errored state, so the children re-throw instantly β€” our first Retry button looped straight back to the error screen without a single network request. The fix is to retry the data, then the UI:

<button onClick={() => { refetch(); reset(); }}>Retry</button>

Test both halves from the URL bar: ?mock_status=500 forces every response to be a 500 (our run: boundary caught "API 500"), and ?mock_chaos=0.5 makes a random half of requests fail so Retry has something real to recover from β€” in our run it took two retries to get the list back, exactly the flow your users will hit on a flaky train connection.

7. Gotcha #3: the keystroke flash β€” .latest

Reading products() under Suspense re-triggers the fallback on every source change. With a realistic 1.5 s latency (?mock_delay=1500), each search keystroke blanked the whole list and flashed "Loading…" β€” we counted: 10 items β†’ 0 items β†’ fallback β†’ 7 items. The stale-while-revalidate fix is one property:

<For each={products.latest}>   // keeps the previous list while refetching

Same run with .latest: the 10 old items stayed on screen during the refetch, then settled to the 7 matches. You only see the difference because the mock has real latency β€” against localhost-fast responses, both versions look identical and the flash ships to production.

8. Writes persist

const res = await fetch(`${BASE}/products`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ name: "Solid Test Product", price: 9.99 }),
});
const created = await res.json();  // β†’ { id: 31, ... }  β€” a real row, not an echo
refetch();

Unlike echo-only fake APIs, the POST inserts a real record: our test's id 31 was immediately fetchable at /products/31 and survived refreshes until we deleted it. One honest footnote from the run: with limit=10 the new record lands beyond page one β€” fetch with ?sortBy=id&order=desc if you want newest-first.

9. Honest comparison

An imported JSON file is offline and zero-dependency, but it can't suspend, can't error, has no latency, and writes go nowhere β€” every state your async UI exists for is unrepresentable.

MSW is excellent for in-process JS tests, and if you're testing Solid components in Vitest it's a great fit (they compose with this setup fine). But the mock lives inside your JS runtime: your phone, your teammate, Postman and your deployed preview build all get nothing, and delay/error scenarios are handler code you maintain.

Imported JSONMSWMockbird
Exercises Suspense/ErrorBoundaryβ€”βœ” handler codeβœ” from the URL bar
Reachable from phone / teammate / CI previewβ€”β€”βœ” hosted URL
Writes persistβ€”DIY stateβœ” real database
Delay / error / chaos injectionβ€”βœ” in code?mock_delay / ?mock_status / ?mock_chaos
Where it winsZero deps, offlineUnit tests, offline, zero latencyβ€”

10. Ship day: swap the env var

Mockbird follows plain REST conventions, so moving to the real backend is changing VITE_API_BASE. Nothing else changes. If the backend team wants a contract, hand them the mock's live spec β€” https://mockbird.mockbird.workers.dev/m/abc123/openapi.json β€” or the generated Postman collection.

The same flow works for React, Vue, Next.js, Nuxt, SvelteKit, Angular, Astro and htmx. Free tier: 20 projects, 10k requests/project/day, no signup. Docs β†’
⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, 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.