You scaffolded the App Router project, wrote the page, and now you need data. The real API isn't ready (it never is), so the choice is: hard-code an array, write throwaway route handlers, or point your Server Components at a real hosted API with realistic data β one whose loading.tsx and error.tsx you can actually trigger on demand. This guide does the third one.
Click "try it" on the Mockbird landing page, or from a terminal:
curl -X POST https://HOST/api/projects \
-H 'content-type: application/json' -d '{"name":"nextjs-demo"}'
# β {"id":"abc123","adminKey":"...","baseUrl":"https://HOST/m/abc123"}
curl -X POST https://HOST/api/projects/abc123/resources \
-H 'content-type: application/json' -H 'x-admin-key: YOUR_KEY' \
-d '{"name":"users","template":"users","seed":50}'
You now have 50 realistic users β names, emails, avatars, cities β at https://HOST/m/abc123/users. No signup, no config. Server Components fetch it server-side (CORS never even comes up), and CORS is on by default anyway for the Client Component moments.
curl -o app/lib/api.types.ts https://HOST/m/abc123/types.ts
That file compiles under strict and exports User plus UserInput (same shape, id optional, for creates). Prefer runtime validation at the fetch boundary? ?format=zod gives you Zod schemas instead.
Put the base URL in .env.local β server-only is fine, Server Components read it on the server:
# .env.local (mock today)
API_URL=https://HOST/m/abc123
# .env.production (later, when the real backend exists)
API_URL=https://api.yourapp.com
Then the page is just an async component. Pagination and search are real query params; the total count arrives in the X-Total-Count header. (Next 15 note: searchParams is a Promise now β await it.)
// app/users/page.tsx
import { User } from '../lib/api.types';
const API = process.env.API_URL; // e.g. https://HOST/m/abc123
export default async function UsersPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; q?: string }>;
}) {
const { page = '1', q = '' } = await searchParams;
const params = new URLSearchParams({ page, limit: '10' });
if (q) params.set('search', q);
const res = await fetch(`${API}/users?${params}`);
if (!res.ok) throw new Error(`API responded ${res.status}`);
const users: User[] = await res.json();
const total = Number(res.headers.get('X-Total-Count'));
return (
<main>
<ul>
{users.map(u => (
<li key={u.id}>
{u.firstName} {u.lastName} β {u.city}
</li>
))}
</ul>
<p>{total} users</p>
</main>
);
}
In Next 15, fetch is no longer cached by default β which is exactly what you want against a mock: edit a record in the data browser, reload, see it. When you want to rehearse ISR for real, opt in per request: fetch(url, { next: { revalidate: 60 } }) β that works against Mockbird like against any other API.
Sorting and range filters are real too: ?sortBy=lastName&order=asc, ?createdAt_gte=2026-01-01, ?search=ana across fields. Your pagination UI gets exercised against an API that actually paginates.
The App Router gives you file conventions for the states everyone forgets to test β but with a hard-coded array or an instant local stub, loading.tsx flashes by in 0ms and error.tsx never renders at all. Mockbird misbehaves on demand:
// app/users/loading.tsx β streamed while the Server Component awaits
export default function Loading() {
return <p>Loading usersβ¦</p>; // your skeleton goes here
}
// app/users/error.tsx β must be a Client Component
'use client';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<p>Something broke: {error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Now trigger both, with zero code changes β just append a flag to the URL your page fetches (or hand it to QA as a full page URL):
API_URL=https://HOST/m/abc123 # normal
?mock_delay=2000 β loading.tsx actually renders for 2s
?mock_status=500 β fetch !ok β throw β error.tsx renders, reset() re-runs it
?mock_status=401 β rehearse your auth redirect
?mock_chaos=0.3 β 30% random 5xx: does retry + error UI hold up?
Tip: while building, you can bake the flag into the env var itself β point API_URL at the mock and add params.set('mock_delay','2000') behind a DEBUG_SLOW=1 check, or just edit the URL bar. Full recipes (race conditions, retry/backoff, error matrices): testing loading & error states.
Full CRUD is live, so form flows work end to end β POST gets the next id, the data persists between reloads and between teammates:
// app/users/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { UserInput } from '../lib/api.types';
export async function createUser(input: UserInput) {
const res = await fetch(`${process.env.API_URL}/users`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`create failed: ${res.status}`);
revalidatePath('/users');
}
Broke the dataset while experimenting? Re-seed from the dashboard, or save a snapshot first and restore it in your E2E beforeEach β every Playwright worker can even pin its own snapshot with one header.
Both are good tools; here's the honest split.
Hand-rolled route handlers (app/api/users/route.ts returning fixtures) are the zero-dependency move, and for one endpoint they're fine. But now you're writing the mock: pagination, filtering, sorting, persistence, error simulation are all code you maintain, the data resets on every restart, and it only exists while your dev server runs β the teammate building the mobile app or the backend gets nothing.
MSW is excellent in the browser and for unit tests. But in the App Router your data fetching moved server-side, and intercepting Next's server-side fetch has been a long-running community struggle (mswjs/msw#1644 β the maintainer describes MSW as blocked by how Next.js works internally; current setups route through the instrumentation hook and are famously fiddly). A hosted mock sidesteps the whole problem: the Server Component just fetches a URL. More in our MSW comparison.
| Route-handler fixtures | MSW | Mockbird | |
|---|---|---|---|
| Server Component fetches | β (localhost URL) | Fiddly (instrumentation hook) | β plain URL |
| Pagination / filters / sort | You write them | You write them | Built in |
| Data persistence | Resets on restart | Resets per reload | Persists, shared with team + CI |
| Delay / error / chaos injection | You write it | You write it | ?mock_delay / ?mock_status / ?mock_chaos |
| Usable outside your dev server | β | β | curl, Postman, mobile, deployed previews, teammates |
| Where it wins | Zero deps, fully offline | Offline unit tests, intercepts the real URL | β |
Mockbird follows plain REST conventions, so moving to the real backend is changing API_URL. Nothing else changes. If the backend team wants a contract to build against, hand them the mock's live spec β https://HOST/m/abc123/openapi.json β or the generated Postman collection.