← All guides

Mock a REST API your Flutter app can actually reach (mobile + web)

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:

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.

1. Get an API (10 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.

2. Point Flutter at it

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',
);

3. The model

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.

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

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.

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

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.

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

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.

What about json-server or http_mock_adapter?

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 fixturesjson-server (laptop)http_mock_adapter / mockitoMockbird
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 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 --dart-define

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.

The same flow works for React Native, native Android (Kotlin/Retrofit), React, Next.js, Vue, SvelteKit and Angular β€” one project can feed the mobile 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.