Every "mock your API locally" tutorial assumes the app runs on the machine that serves the mock. A Flutter app doesn't β it runs on an emulator, a simulator, a real phone, or in a browser tab, and each one sees http://localhost:3000 differently:
localhost is the emulator itself; your machine lives at the alias 10.0.2.2. Every base URL forks per platform.localhost works⦠until you plug in a real device.http:// is blocked by default on Android 9+ (cleartext policy) and by iOS App Transport Security. The mock that worked all sprint dies in the TestFlight build with a vague SocketException.XMLHttpRequest error even when the URL is reachable.A hosted mock is the boring fix: one https URL, CORS on by default, that works identically from every emulator, simulator, device, browser tab, teammate's machine, and CI job. 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://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"flutter-demo"}'
# β {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123"}
curl -X POST https://mockbird.mockbird.workers.dev/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://mockbird.mockbird.workers.dev/m/abc123/users. No signup. It's https, so there is nothing to configure on Android or iOS, and CORS headers are already there for the web build.
Use --dart-define so the base URL is a build-time setting, not a code edit:
flutter run --dart-define=API_URL=https://mockbird.mockbird.workers.dev/m/abc123
# later, when the real backend exists:
flutter build apk --dart-define=API_URL=https://api.yourapp.com
Read it with a const String.fromEnvironment (the default keeps plain flutter run working):
const apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'https://mockbird.mockbird.workers.dev/m/abc123',
);
The seeded users template gives every record id, firstName, lastName, email, avatar, city, createdAt. A plain model class is enough (swap in json_serializable or freezed if that's your house style):
class User {
final int id;
final String firstName;
final String lastName;
final String email;
final String city;
const User({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
required this.city,
});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
email: json['email'] as String,
city: json['city'] as String,
);
}
Prefer generated code? Every project also serves a live openapi.json (https://mockbird.mockbird.workers.dev/m/abc123/openapi.json) β point openapi-generator's Dart generators at it and your models track the mock's actual schema.
Pagination is real (?page=&limit=) and the total row count arrives in the X-Total-Count header (Dart's http package lower-cases header names). So ListView.builder + RefreshIndicator get exercised against an API that actually paginates:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class UsersScreen extends StatefulWidget {
const UsersScreen({super.key});
@override
State<UsersScreen> createState() => _UsersScreenState();
}
class _UsersScreenState extends State<UsersScreen> {
final _users = <User>[];
final _scroll = ScrollController();
int _page = 1;
int _total = 0;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load(1, replace: true);
_scroll.addListener(() {
final nearBottom = _scroll.position.pixels >
_scroll.position.maxScrollExtent - 200;
if (nearBottom && !_loading && _users.length < _total) {
_load(_page + 1);
}
});
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
Future<void> _load(int page, {bool replace = false}) async {
setState(() { _loading = true; _error = null; });
try {
final res = await http
.get(Uri.parse('$apiUrl/users?page=$page&limit=20'));
if (res.statusCode != 200) {
throw Exception('API responded ${res.statusCode}');
}
final batch = (jsonDecode(res.body) as List)
.map((j) => User.fromJson(j as Map<String, dynamic>))
.toList();
setState(() {
_total = int.tryParse(res.headers['x-total-count'] ?? '') ?? 0;
if (replace) _users.clear();
_users.addAll(batch);
_page = page;
_loading = false;
});
} catch (e) {
setState(() { _error = '$e'; _loading = false; });
}
}
@override
Widget build(BuildContext context) {
if (_loading && _users.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _users.isEmpty) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text('Something broke: $_error'),
ElevatedButton(
onPressed: () => _load(1, replace: true),
child: const Text('Try again'),
),
]),
);
}
return RefreshIndicator(
onRefresh: () => _load(1, replace: true),
child: ListView.builder(
controller: _scroll,
itemCount: _users.length,
itemBuilder: (context, i) {
final u = _users[i];
return ListTile(
title: Text('${u.firstName} ${u.lastName}'),
subtitle: Text(u.city),
);
},
),
);
}
}
Sorting, search and range filters are real too: ?sortBy=lastName&order=asc, ?search=wilson (any-field substring), ?createdAt_gte=2026-01-01. One heads-up: the seeded avatar URLs are SVG, which Image.network won't decode β use SvgPicture.network from flutter_svg if you want them, or ignore the field.
Your dev loop runs on office Wi-Fi; your users run on the subway. The spinner, the error column and the retry button above are exactly the code that never gets exercised β unless the API misbehaves on purpose. Append a flag to any request (or bake it into API_URL while you style the states):
?mock_delay=3000 β 3s of spinner: does the layout hold? RefreshIndicator feel right?
?mock_status=500 β the error column + "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
?mock_ratelimit=5 β real 429s + Retry-After once you burn 5 requests/min
No code changes, no rebuild β same URL, one query param. Full recipes: testing loading & error states and mocking rate limits.
Future<User> createUser(Map<String, dynamic> input) async {
final res = await http.post(
Uri.parse('$apiUrl/users'),
headers: {'content-type': 'application/json'},
body: jsonEncode(input),
);
if (res.statusCode != 201) {
throw Exception('create failed: ${res.statusCode}');
}
return User.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
}
POST returns 201 with 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 integration-test setUp β each test worker can even pin its own snapshot with a header, so parallel runs stay deterministic.
Both are good tools; here's the honest split for Flutter 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: platform-specific hosts (10.0.2.2 vs localhost vs LAN IP), same-Wi-Fi requirements, 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.
http_mock_adapter (for dio) and mockito-stubbed http.Clients intercept in-process β no network hop, they work offline, and they're the right tool for fast unit tests. The trade-offs: the mock exists only inside that test process (nothing for a real device, your teammate, or the backend dev to hit), and pagination, filtering, persistence and error simulation are code you write and maintain per test.
| Hard-coded fixtures | json-server (laptop) | http_mock_adapter / mockito | Mockbird | |
|---|---|---|---|---|
| Reachable from emulator + simulator + device | β (in bundle) | Per-platform URLs, firewall, same Wi-Fi | β (in-process) | β one https URL |
| Works in release / TestFlight builds | β but fake forever | β (cleartext block + laptop off) | β tests only | β |
| Works in Flutter web (CORS) | β | Only with the laptop reachable | β tests only | β CORS on by default |
| 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 one build flag. Nothing else changes. Hand the backend team the mock's live contract while they build: https://mockbird.mockbird.workers.dev/m/abc123/openapi.json, or the generated Postman collection.