← All guides

Mock a REST API your phone can actually reach (React Native / Expo)

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:

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.

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":"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.

2. Point Expo at it

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.

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

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.

4. The screen: FlatList with real pagination and pull-to-refresh

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.

5. Loading, error and flaky-network states on demand

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.

6. Writes persist β€” and every device sees them

// 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).

What about json-server or MSW?

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 fixturesjson-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 themBuilt in
Data shared across devices + teammatesβœ–Same network onlyβœ– per processβœ”
Delay / error / chaos injectionβ€”You write itYou write it?mock_delay / ?mock_status / ?mock_chaos
Works offlineβœ”βœ”βœ”βœ– (needs network)

7. Ship day: change one env var

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.

The same flow works for React (web), Next.js, Vue and Angular β€” one project can feed the app and the web dashboard at once. Free tier: 20 projects, 10k requests/project/day, no signup. Docs β†’
⚑ Skip the terminal: this link creates a live, seeded 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.