Integrating a REST API is the most common job in a real Flutter app, and the parts that trip people up are rarely the HTTP call itself. The hard bits are structure — where the networking lives — and the three states every request passes through: loading, error, and data. This tutorial wires the whole thing end to end: a service that fetches, a model and a repository that turn JSON into typed objects, a widget that drives loading, error and data states, a ListView that renders the result, and a refresh that pulls fresh data, all with paste-ready code.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowThe example fetches a list of posts from a public test API, but the shape applies to any endpoint. Add the http package with http: ^1.2.0 in pubspec.yaml, run flutter pub get, and import it where the service lives.
I share quick wins on app architecture and clean networking layers on Instagram throughout the week.
On LinkedIn I write about the trade-offs behind service and repository layers in production apps.
The shape of a clean integration
A tidy integration has three layers, each with one job. The service talks to the network and returns a decoded body. The model is a Dart class that maps one JSON object. The repository calls the service and converts the raw body into a list of models — it is the single door the UI knocks on.
The payoff is separation. Widgets never see http or jsonDecode; they ask the repository for a List<Post> and react to the result. That makes the networking swappable — you could move to Dio later — and testable, because each layer can be checked on its own.
A service that fetches
The service is thin. It performs the request, checks the status code, and returns the decoded JSON, throwing on a non-2xx response so the layers above can surface an error.
import 'dart:convert';
import 'package:http/http.dart' as http;
class PostService {
final _base = Uri.parse('https://jsonplaceholder.typicode.com/posts');
Future<List<dynamic>> fetchPostsJson() async {
final res = await http.get(_base);
if (res.statusCode != 200) {
throw Exception('Request failed (${res.statusCode})');
}
return jsonDecode(res.body) as List<dynamic>;
}
}
Note that it returns raw dynamic JSON, not models — turning that into typed objects is the repository's job. For the GET and POST mechanics in more depth, see the http requests tutorial.
A model and a repository
The model gives the JSON a shape. A fromJson factory keeps the mapping in one place so a stray field name only breaks one line.
class Post {
final int id;
final String title;
Post({required this.id, required this.title});
factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json['id'] as int,
title: json['title'] as String,
);
}
class PostRepository {
final _service = PostService();
Future<List<Post>> getPosts() async {
final raw = await _service.fetchPostsJson();
return raw
.map((e) => Post.fromJson(e as Map<String, dynamic>))
.toList();
}
}
The repository is where raw JSON becomes typed data the UI can trust. If you want to go deeper on factories, nested objects, and null safety in mapping, the parse JSON tutorial covers it.

Build a real API-driven app, end to end
The Complete Flutter Guide wires services, repositories, models, and state into apps you actually ship.
Enrol nowDrive the UI with state
Every fetch passes through three states: loading, error, and data. The cleanest way to render them is FutureBuilder, which exposes those branches through its snapshot. Hold the Future in a field so it isn't recreated on every rebuild.
class PostsPage extends StatefulWidget {
const PostsPage({super.key});
@override
State<PostsPage> createState() => _PostsPageState();
}
class _PostsPageState extends State<PostsPage> {
final _repo = PostRepository();
late Future<List<Post>> _future;
@override
void initState() {
super.initState();
_future = _repo.getPosts();
}
// build() comes next
}
Creating the future in initState rather than inside build is important — otherwise a rebuild fires a fresh request every frame. For the full comparison of when to use FutureBuilder versus StreamBuilder, see the FutureBuilder vs StreamBuilder tutorial.
Display it in a ListView
Inside build, FutureBuilder maps the snapshot to a spinner, an error with a retry button, or the list. Render the data with ListView.builder so only visible rows are built.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Posts')),
body: FutureBuilder<List<Post>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Could not load posts.'),
TextButton(
onPressed: () => setState(() => _future = _repo.getPosts()),
child: const Text('Retry'),
),
],
),
);
}
final posts = snapshot.data ?? [];
return ListView.builder(
itemCount: posts.length,
itemBuilder: (_, i) => ListTile(title: Text(posts[i].title)),
);
},
),
);
}
That single builder covers all three states. The retry button reassigns _future inside setState, which re-runs the request and rebuilds. For more list patterns, see the ListView.builder tutorial.
Refresh the data
Refreshing is the same trick as retry: re-run the fetch and rebuild. For a user-driven pull, wrap the list in a RefreshIndicator and return the fetch Future from onRefresh so the spinner stays visible until the new data lands.
RefreshIndicator(
onRefresh: () async {
final fresh = _repo.getPosts();
setState(() => _future = fresh);
await fresh; // keep the spinner until data arrives
},
child: ListView.builder(
itemCount: posts.length,
itemBuilder: (_, i) => ListTile(title: Text(posts[i].title)),
),
)
Returning the Future is what ties the indicator to the request. For the full pull-to-refresh behaviour, including short-list scroll physics, see the pull to refresh tutorial.
Make failures first-class
The service above throws a generic Exception, so the UI can only say "something went wrong". In production you want to tell a timeout from an offline device from a 404. Give the service a typed error and map the common failures in one place.
import 'dart:async'; // TimeoutException
import 'dart:io'; // SocketException
class ApiException implements Exception {
final String message;
final int? statusCode;
ApiException(this.message, {this.statusCode});
}
class PostService {
final _client = http.Client();
final _base = Uri.parse('https://jsonplaceholder.typicode.com/posts');
Future<List<dynamic>> fetchPostsJson() async {
try {
final res = await _client.get(_base).timeout(const Duration(seconds: 15));
if (res.statusCode == 200) {
return jsonDecode(res.body) as List<dynamic>;
}
throw ApiException('Server error', statusCode: res.statusCode);
} on SocketException {
throw ApiException('You appear to be offline');
} on TimeoutException {
throw ApiException('The request timed out');
}
}
}
The repository lets ApiException bubble up untouched, and the error branch reads snapshot.error to show a real message instead of a blanket one:
if (snapshot.hasError) {
final e = snapshot.error;
final msg = e is ApiException ? e.message : 'Could not load posts';
return Center(child: Text(msg)); // plus your Retry button
}
Handle the empty state
A successful request that returns zero rows is not an error and not loading — it is its own state. Check it before building the list, or users stare at a blank screen and assume the app is broken.
final posts = snapshot.data ?? [];
if (posts.isEmpty) {
return const Center(child: Text('No posts yet.'));
}
return ListView.builder(/* ... */);
Paginate: load more on scroll
Most list endpoints are paged. Pass a page (here _page and _limit) query parameter, append each batch to a growing list, and fetch the next page as the user nears the end. Hold the accumulated items and a page counter in your State.
final _posts = <Post>[];
int _page = 1;
bool _loading = false, _hasMore = true;
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
setState(() => _loading = true);
final url = Uri.https('jsonplaceholder.typicode.com', '/posts', {
'_page': '$_page',
'_limit': '20',
});
final res = await http.get(url);
final batch = (jsonDecode(res.body) as List)
.map((e) => Post.fromJson(e as Map<String, dynamic>))
.toList();
setState(() {
_page++;
_posts.addAll(batch);
_hasMore = batch.isNotEmpty;
_loading = false;
});
}
Trigger _loadMore from a ScrollController listener (or a NotificationListener<ScrollNotification>) when the offset is within a screen of the bottom, and show a small spinner in the final row while _loading is true.
Cache results in the repository
If several screens ask for the same data, refetching every time wastes bandwidth and flickers the UI. The repository is the natural place for a simple in-memory cache that only hits the network when asked to refresh.
class PostRepository {
PostRepository(this._service);
final PostService _service;
List<Post>? _cache;
Future<List<Post>> getPosts({bool forceRefresh = false}) async {
if (_cache != null && !forceRefresh) return _cache!;
final raw = await _service.fetchPostsJson();
_cache = raw.map((e) => Post.fromJson(e as Map<String, dynamic>)).toList();
return _cache!;
}
}
Pull-to-refresh then calls getPosts(forceRefresh: true), while ordinary navigation reuses the cache. For anything longer-lived than a session, persist to local storage rather than a field.
Inject dependencies so you can test
Notice the repository now takes its service in the constructor instead of creating one with PostRepository(). That single change — constructor injection — lets you pass a fake service in tests and the real one in the app, so you can verify the loading, empty, and error paths without a live server.
// app
final repo = PostRepository(PostService());
// test — FakePostService returns canned JSON or throws ApiException
final repo = PostRepository(FakePostService());
The same idea scales up: as the app grows, hand the repository to your widgets through a provider rather than constructing it inside initState, so the whole tree shares one instance.
Common mistakes
- Networking inside widgets. Keep
httpandjsonDecodein the service and repository, not inbuild. - Creating the Future in build. Build it in
initStateor a refresh handler, or every rebuild fires a new request. - Ignoring the error state. Always handle
snapshot.hasErrorwith a message and a retry, not just loading and data. - Not checking the status code. A 404 still returns a body; verify
statusCode == 200before decoding. - Mapping JSON in the UI. Convert to typed models in the repository so widgets stay free of parsing detail.
- Treating an empty list as an error. Zero rows is a valid result — give it its own empty state.
- Refetching on every visit. Cache in the repository and only force a network call on refresh.
- Hard-coding dependencies. Inject the service so the error and empty paths are testable.
Frequently asked questions
How do I integrate a REST API in Flutter?
Split it into a service that makes the HTTP call, a model that maps each JSON object, and a repository that returns typed objects. Your widget calls the repository and renders a loading, error, or data state from the result.
Where should the networking code live?
Out of your widgets. Put raw requests in a service and the mapping to models in a repository, so the UI only asks the repository for data. That lets you swap libraries, add caching, or test without touching the screen.
How do I show loading and error states?
Model the request as three states and render each branch. A FutureBuilder gives you waiting, hasError, and data through its snapshot, so you show a spinner, an error with retry, or the list.
How do I refresh the data?
Re-run the fetch and rebuild. Reassign the Future inside setState, or wrap the list in a RefreshIndicator and return the fetch Future from onRefresh for a pull-to-refresh gesture.
Further reads
Keep going with the tutorials that pair with this guide:
- Flutter Development Guide 2026 — the full Flutter hub.
- Flutter HTTP Requests — the GET/POST layer.
- Flutter Parse JSON — the models.
- Flutter FutureBuilder vs StreamBuilder — render the states.
- Flutter Pull to Refresh — refresh the list.
- Flutter Handle API Errors — retries, recovery, and user feedback.
- Flutter Infinite Scroll Pagination — load more pages as the user scrolls.
Sources: Flutter documentation — networking cookbook and app-architecture guidance; the http package (docs.flutter.dev, pub.dev/packages/http). Verified against current stable Flutter.