← All guides

Mock a REST API for your Next.js app in 60 seconds

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.

1. Get an API (10 seconds)

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.

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

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.

3. Fetch it from a Server Component

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.

4. The part Next.js makes special: loading.tsx and error.tsx you can trigger

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.

5. Writes: Server Actions against the mock

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.

What about route handlers or MSW?

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 fixturesMSWMockbird
Server Component fetchesβœ” (localhost URL)Fiddly (instrumentation hook)βœ” plain URL
Pagination / filters / sortYou write themYou write themBuilt in
Data persistenceResets on restartResets per reloadPersists, shared with team + CI
Delay / error / chaos injectionYou write itYou write it?mock_delay / ?mock_status / ?mock_chaos
Usable outside your dev serverβ€”β€”curl, Postman, mobile, deployed previews, teammates
Where it winsZero deps, fully offlineOffline unit tests, intercepts the real URLβ€”

6. Ship day: swap the env var

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.

The same flow works for React (SPA), Vue, Angular and React Native / Expo. Free tier: 20 projects, 10k requests/project/day, no signup. Docs β†’
⚑ Skip the terminal: this link creates a live, seeded SaaS backend (users, teams, events…) 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.