← All guides

Mock a REST API for Remix / React Router in 60 seconds

Remix merged into React Router β€” since v7, react-router in framework mode is Remix: loaders, actions, ErrorBoundary, <Form>, all there. What hasn't changed: your loaders need an API to fetch from, and the real backend usually doesn't exist yet. You can hard-code arrays in loaders, mock fetch in-process, or point loaders at a real hosted API with realistic data whose failure modes you control from the URL. This guide does the third one.

Every snippet below ran before publishing, in a fresh npx create-react-router project (React Router 8.3.0, Vite 8, Node 22, TypeScript): SSR curl-checked, the 404 and 500 error boundaries triggered for real, the pending indicator watched appearing and disappearing in a real browser against ?mock_delay=1500, the form action's write confirmed persisted in the mock's database, tsc clean, and all 4 Vitest tests green. Both gotchas below come from that run.

1. Get an API (10 seconds)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -d '{"name":"remix-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. Loaders run on the server, so the base URL goes in a plain (non-VITE_) env var. One small module reads it β€” and also handles snapshot pinning for tests (section 7):

// Base URL of your Mockbird project. Override per environment:
//   MOCKBIRD_URL=https://mockbird.mockbird.workers.dev/m/<your-project>
export const API =
  (typeof process !== "undefined" && process.env.MOCKBIRD_URL) ||
  "https://mockbird.mockbird.workers.dev/m/demo";

// In tests: MOCKBIRD_SNAPSHOT=<name> pins every read to a saved snapshot,
// so parallel test files can't step on each other's data.
export const apiHeaders: HeadersInit =
  typeof process !== "undefined" && process.env.MOCKBIRD_SNAPSHOT
    ? { "X-Mockbird-Snapshot": process.env.MOCKBIRD_SNAPSHOT }
    : {};

export type Product = {
  id: number;
  name: string;
  price: number;
  category: string;
  inStock: boolean;
};

No terminal handy? Everything below also works against the public demo project (/m/demo) β€” that's what the defaults do. Generate the types instead of writing them:

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

2. Register routes β€” framework mode is explicit now

Gotcha #1 if you're coming from Remix v2: there is no flat-file routing by default anymore. React Router v7+ wants every route listed in app/routes.ts (the old file convention is available via the @react-router/fs-routes package):

import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("products", "routes/products.tsx"),
  route("products/:id", "routes/product.tsx"),
  route("products/new", "routes/new-product.tsx"),
] satisfies RouteConfig;

3. A loader-backed list page

import type { Route } from "./+types/products";
import { Link } from "react-router";
import { API, apiHeaders, type Product } from "../lib/api";

export async function loader() {
  const res = await fetch(`${API}/products?_limit=5&sortBy=price&order=desc`, {
    headers: apiHeaders,
  });
  if (!res.ok) throw new Response(`Upstream returned ${res.status}`, { status: 502 });
  const products: Product[] = await res.json();
  return { products };
}

export default function Products({ loaderData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Products</h1>
      <ul>
        {loaderData.products.map((p) => (
          <li key={p.id}>
            <Link to={`/products/${p.id}`}>{p.name}</Link> β€” ${p.price}
          </li>
        ))}
      </ul>
      <Link to="/products/new">Add a product</Link>
    </main>
  );
}

That's the whole data story: the loader runs on the server (and on client navigations), loaderData arrives typed via the generated ./+types/products. The mock speaks json-server conventions β€” _limit, sortBy, order, ?category=…, ?q=… full-text search β€” so the URL params above are real, not decoration. We verified the SSR output renders 5 products sorted by price descending.

4. Dynamic segment + a 404 that actually 404s

import type { Route } from "./+types/product";
import { isRouteErrorResponse, Link } from "react-router";
import { API, type Product } from "../lib/api";

export async function loader({ params }: Route.LoaderArgs) {
  const res = await fetch(`${API}/products/${params.id}`);
  if (res.status === 404) throw new Response("No such product", { status: 404 });
  if (!res.ok) throw new Response(`Upstream returned ${res.status}`, { status: 502 });
  const product: Product = await res.json();
  return { product };
}

export default function ProductPage({ loaderData }: Route.ComponentProps) {
  const p = loaderData.product;
  return (
    <main>
      <h1>{p.name}</h1>
      <p>
        ${p.price} Β· {p.category} Β· {p.inStock ? "in stock" : "out of stock"}
      </p>
      <Link to="/products">Back</Link>
    </main>
  );
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  if (isRouteErrorResponse(error)) {
    return (
      <main data-testid="error">
        <h1>{error.status === 404 ? "Product not found" : "Something broke"}</h1>
        <p>{error.data}</p>
        <Link to="/products">Back to the list</Link>
      </main>
    );
  }
  return <main data-testid="error"><h1>Unexpected error</h1></main>;
}

Throwing a Response from a loader is the Remix-idiomatic way to fail: React Router sets the real HTTP status on the SSR response and renders the nearest ErrorBoundary. We curl-checked both paths: /products/1 renders the product, /products/99999 returns an actual HTTP 404 with "Product not found" β€” because the mock returns a real 404 for a missing record, like your production API will.

5. See your error states without breaking anything

Append a simulation param to the loader's fetch and reload β€” no code branches, no waiting for the backend to be down:

ParamWhat you get
?mock_status=500That status, on demand. Our loader turned it into a thrown 502 and the error boundary rendered β€” verified.
?mock_delay=15001.5s latency β€” makes pending UI reviewable (next section)
?mock_chaos=0.330% of requests fail randomly (500/502/503/504/429) β€” retry logic testing
?mock_jitter=800Random 0–800ms latency on top

6. Pending UI β€” and the gotcha that hides it

Gotcha #2, which we hit for real: we first put a useNavigation() spinner inside the products route component and it never showed. Of course β€” during a client navigation the destination component doesn't render until its loader resolves; while the 1.5s mock_delay was ticking, the browser was still showing the previous page. Global pending UI belongs in a component that's already on screen β€” root.tsx:

// app/root.tsx β€” add to the existing default export
import { Outlet, useNavigation /* … */ } from "react-router";

export default function App() {
  const nav = useNavigation();
  return (
    <>
      {nav.state === "loading" && (
        <p data-testid="pending" style={{ position: "fixed", top: 0, right: 8 }}>
          Loading…
        </p>
      )}
      <Outlet />
    </>
  );
}

With the loader pointed at &mock_delay=1500 we watched it in a real browser: indicator appears on click, list arrives 1.5s later, indicator gone. (For per-link feedback, <NavLink>'s isPending render prop works the same way.)

7. Actions: a form whose writes persist

import type { Route } from "./+types/new-product";
import { Form, redirect, useNavigation } from "react-router";
import { API } from "../lib/api";

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const res = await fetch(`${API}/products`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      name: form.get("name"),
      price: Number(form.get("price")),
      category: "misc",
      inStock: true,
    }),
  });
  if (!res.ok) return { error: `Create failed (${res.status})` };
  const created = await res.json();
  return redirect(`/products/${created.id}`);
}

export default function NewProduct({ actionData }: Route.ComponentProps) {
  const nav = useNavigation();
  return (
    <main>
      <h1>New product</h1>
      <Form method="post">
        <input name="name" placeholder="Name" required />
        <input name="price" type="number" step="0.01" required />
        <button disabled={nav.state === "submitting"}>
          {nav.state === "submitting" ? "Saving…" : "Save"}
        </button>
      </Form>
      {actionData?.error && <p data-testid="err">{actionData.error}</p>}
    </main>
  );
}

Submit the form and the action POSTs to the mock, gets the created record back, and redirects to its brand-new detail page β€” which loads, because the write went into a real database. We submitted "Browser Form Product" in a real browser, landed on /products/31, and fetched the same record back from the API directly. JSONPlaceholder-style fake-write APIs return {"id": 31} and forget you ever asked; here the whole loader/action round-trip is honest.

8. Test loaders as what they are: functions

Loaders and actions are plain async functions, so integration-testing them doesn't need a running app server or a mocked fetch:

import { describe, expect, it } from "vitest";
import { loader as productsLoader } from "../app/routes/products";
import { loader as productLoader } from "../app/routes/product";

describe("products loader", () => {
  it("returns 5 products sorted by price desc", async () => {
    const { products } = await productsLoader();
    expect(products).toHaveLength(5);
    const prices = products.map((p) => p.price);
    expect(prices).toEqual([...prices].sort((a, b) => b - a));
  });
});

describe("product loader", () => {
  it("loads one product by id", async () => {
    const { product } = await productLoader({ params: { id: "1" } } as any);
    expect(product.id).toBe(1);
    expect(typeof product.name).toBe("string");
  });

  it("throws a 404 Response for a missing id", async () => {
    try {
      await productLoader({ params: { id: "99999" } } as any);
      expect.unreachable("loader should have thrown");
    } catch (e) {
      expect(e).toBeInstanceOf(Response);
      expect((e as Response).status).toBe(404);
    }
  });
});

For tests that need a known dataset, pin them to a saved snapshot. Save one per scenario on your project (admin key from step 1):

# save the current data as "baseline"
curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/snapshots \
  -H 'x-admin-key: KEY' -d '{"name":"baseline"}'
# wipe products to zero records, save that as "empty", then restore baseline
curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/resources/products \
  -H 'x-admin-key: KEY' -d '{"seed":0}'
curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/snapshots \
  -H 'x-admin-key: KEY' -d '{"name":"empty"}'
curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/snapshots/baseline/restore \
  -H 'x-admin-key: KEY'

Any read sent with X-Mockbird-Snapshot: empty is answered from that snapshot, read-only, no matter what the live data looks like β€” that's what the apiHeaders export in section 1 wires through. So a test file can pin itself:

// Pin this file's reads to a saved snapshot: no other test file (or teammate)
// can change what these assertions see, even though the API is shared.
process.env.MOCKBIRD_URL = "https://mockbird.mockbird.workers.dev/m/abc123";
process.env.MOCKBIRD_SNAPSHOT = "empty";

const { loader } = await import("../app/routes/products");

import { describe, expect, it } from "vitest";

describe("empty state (snapshot-pinned)", () => {
  it("renders zero products no matter what the live data is", async () => {
    const { products } = await loader();
    expect(products).toHaveLength(0);
  });
});

Both files ran green together (4 passed): the pinned file saw zero products while the other file saw live data β€” same API, no interference, no beforeEach cleanup choreography. The empty state is the one your happy-path seed data never shows you.

9. Honest comparison

Hard-coded loader dataMSWMockbird
Setupnonehandlers + node/browser wiringone curl
Works offlineβœ”βœ”β€”
Same data for SSR, client nav, teammates, CI, mobile appβœ” if you share codeJS-process-onlyβœ” it's a URL
Writes persist across requestsβ€”DIY stateβœ” real database
Delay / error / chaos from the URLβ€”hand-written per handlerβœ” built in
Where it winszero deps, no network in unit testsoffline tests, mocks the real API's URL transparentlyβ€”

MSW is genuinely great for in-process tests β€” they compose nicely: MSW in unit tests, a hosted mock for everything that isn't your JS process.

10. Ship day: swap the env var

Mockbird follows plain REST conventions, so moving to the real backend is changing MOCKBIRD_URL. Hand the backend team the mock's live contract while you're at it: https://mockbird.mockbird.workers.dev/m/abc123/openapi.json, or the generated Postman collection.

The same flow works for React (SPA), Next.js, Nuxt, SvelteKit, Astro, Solid, Angular 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.