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.
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.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
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;
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.
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.
Append a simulation param to the loader's fetch and reload β no code branches, no waiting for the backend to be down:
| Param | What you get |
|---|---|
?mock_status=500 | That status, on demand. Our loader turned it into a thrown 502 and the error boundary rendered β verified. |
?mock_delay=1500 | 1.5s latency β makes pending UI reviewable (next section) |
?mock_chaos=0.3 | 30% of requests fail randomly (500/502/503/504/429) β retry logic testing |
?mock_jitter=800 | Random 0β800ms latency on top |
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.)
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.
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.
| Hard-coded loader data | MSW | Mockbird | |
|---|---|---|---|
| Setup | none | handlers + node/browser wiring | one curl |
| Works offline | β | β | β |
| Same data for SSR, client nav, teammates, CI, mobile app | β if you share code | JS-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 wins | zero deps, no network in unit tests | offline 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.
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.