Every "mock your API locally" tutorial has the same unspoken assumption: the app runs on the machine that serves the mock. Your React Native app doesn't. It runs on an emulator, a simulator, or a real phone β and from there, http://localhost:3000 is not your laptop:
localhost is the emulator itself; your machine is at the special alias 10.0.2.2. Every fetch URL forks per platform.localhost worksβ¦ right up until you plug in a real device.adb reverse tcp:3000 tcp:3000 per cable session.http:// requests are blocked by default in Android 9+ (cleartext policy) and by iOS App Transport Security. The mock that worked all sprint dies in the TestFlight build, silently, with Network request failed.A hosted mock is the boring fix: one https URL that works identically from every emulator, simulator, device, teammate's device, and CI job β no platform forks, no manifest edits. This guide sets one up in about 60 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":"rn-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. It's https, so there is nothing to configure on either platform, and CORS never comes up in React Native anyway (no browser) β though it's on by default, which matters the day the same project also feeds your web app.
Expo CLI loads variables prefixed EXPO_PUBLIC_ from .env files into your JavaScript:
# .env (mock today)
EXPO_PUBLIC_API_URL=https://HOST/m/abc123
# later, when the real backend exists
EXPO_PUBLIC_API_URL=https://api.yourapp.com
Bare React Native (no Expo)? Same idea via react-native-config, or just a config.ts module you edit per environment.
curl -o src/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? ?format=zod emits Zod schemas instead.
Pagination is real (?page=&limit=), the total arrives in the X-Total-Count header, and onEndReached + RefreshControl get exercised against an API that actually paginates. This compiles under strict with React Native 0.86 / React 19 types:
// UsersScreen.tsx
import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator, Button, FlatList, RefreshControl, Text, View,
} from 'react-native';
import { User } from './api.types';
const API = process.env.EXPO_PUBLIC_API_URL; // https://HOST/m/abc123
export default function UsersScreen() {
const [users, setUsers] = useState<User[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (pageNum: number, replace = false) => {
try {
setError(null);
const res = await fetch(`${API}/users?page=${pageNum}&limit=20`);
if (!res.ok) throw new Error(`API responded ${res.status}`);
const batch: User[] = await res.json();
setTotal(Number(res.headers.get('X-Total-Count')));
setUsers(prev => (replace ? batch : [...prev, ...batch]));
setPage(pageNum);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => { load(1, true); }, [load]);
if (loading) return <ActivityIndicator size="large" style={{ flex: 1 }} />;
if (error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Something broke: {error}</Text>
<Button title="Try again" onPress={() => { setLoading(true); load(1, true); }} />
</View>
);
}
return (
<FlatList
data={users}
keyExtractor={u => String(u.id)}
renderItem={({ item }) => (
<Text>{item.firstName} {item.lastName} β {item.city}</Text>
)}
onEndReached={() => { if (users.length < total) load(page + 1); }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => { setRefreshing(true); load(1, true); }}
/>
}
/>
);
}
Sorting, search and range filters are real too: ?sortBy=lastName&order=asc, ?search=ana (any field), ?createdAt_gte=2026-01-01. One heads-up: the seeded avatar URLs are SVG, which the core <Image> component won't render β use SvgUri from react-native-svg if you want them, or ignore the field.
Your dev loop runs on office Wi-Fi against a warm server; your users run on the subway. The spinner, the error screen and the retry path in the component above are exactly the code that never gets exercised β unless the API misbehaves on purpose. Append a flag to the URL (or bake it into EXPO_PUBLIC_API_URL while you style the states):
?mock_delay=3000 β 3s of spinner: does the skeleton hold? pull-to-refresh feel right?
?mock_status=500 β error screen + "Try again" actually render
?mock_status=401 β rehearse the logout-and-redirect path
?mock_chaos=0.3 β 30% random 5xx: subway mode. Does your retry logic cope?
?mock_jitter=800 β 0β800ms random latency on every request
No code changes, no rebuild β it's the same URL with a query param. Full recipes: testing loading & error states.
// api.ts
import { User, UserInput } from './api.types';
const API = process.env.EXPO_PUBLIC_API_URL;
export async function createUser(input: UserInput): Promise<User> {
const res = await fetch(`${API}/users`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`create failed: ${res.status}`);
return res.json();
}
POST gets the next id and the record is really there β pull-to-refresh on your iPhone shows the user your teammate just created from the Android emulator, because it's one hosted datastore, not a per-device fixture. Broke the data while demoing? Re-seed from the dashboard, or save a snapshot first and restore it in your E2E beforeEach (Detox and Maestro runs stay deterministic; each worker can even pin its own snapshot with a header).
Both are good tools; here's the honest split for mobile specifically.
json-server on your laptop gives you the same REST conventions β that's why our params are compatible β but the URL is the whole problem on mobile: platform-specific hosts (10.0.2.2 vs localhost vs LAN IP), same-Wi-Fi requirements, adb reverse per session, and cleartext-http blocks in release builds. If you already have a db.json, POST it to /api/projects/import and it's hosted β your exact records, same query params, https.
MSW ships a React Native integration (setupServer from msw/native) that intercepts in-process β no network hop, works offline. The trade-offs: it needs polyfills in RN (react-native-url-polyfill, fast-text-encoding), the handlers are code you write and maintain (pagination, filtering, persistence, error simulation), the mock exists only inside the app process (nothing for curl, your teammate, or the backend dev to hit), and it's a dev/test-build tool β not something a stakeholder's TestFlight build can use. Great for unit tests; more in our MSW comparison.
| Hard-coded fixtures | json-server (laptop) | MSW (msw/native) | Mockbird | |
|---|---|---|---|---|
| Reachable from emulator + simulator + device | β (in bundle) | Per-platform URLs, adb reverse, firewall | β (in-process) | β one https URL |
| Works in release / TestFlight builds | β but fake forever | β (cleartext block + laptop off) | β dev/test only | β |
| Pagination / filters / sort / search | β | β | You write them | Built in |
| Data shared across devices + teammates | β | Same network only | β per process | β |
| Delay / error / chaos injection | β | You write it | You write it | ?mock_delay / ?mock_status / ?mock_chaos |
| Works offline | β | β | β | β (needs network) |
Mockbird follows plain REST conventions, so moving to the real backend is editing EXPO_PUBLIC_API_URL. Nothing else changes. Hand the backend team the mock's live contract while they build: https://HOST/m/abc123/openapi.json, or the generated Postman collection.