To connect a REST API in Flutter, add the http package, build a Uri, await an HTTP request, reject unexpected status codes, decode the JSON, map it to a Dart model, and expose loading, success, and error states to the widget tree. The small example below fetches one album from JSONPlaceholder, then extends the same pattern to POST, PUT, DELETE, authentication, testing, and large responses.
What a Flutter REST connection actually does
A REST call is a network conversation between your app and a server. Your Flutter code chooses an HTTP method and URL, optionally sends headers and a body, waits for the server, checks the response, and converts the response payload into values the UI can use.
As an Amazon Associate I earn from qualifying purchases.
The request is asynchronous because the server may be on another network, may be slow, or may fail. A function that performs the call therefore returns a Future. Using await keeps the code readable without blocking Flutter’s main isolate while the network operation is in progress.
Crashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteWindows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallFor ordinary cross-platform requests, Flutter’s networking documentation describes the http package as the simplest way to issue HTTP requests. It supports Android, iOS, macOS, Windows, Linux, and web, so you do not have to write separate request code around dart:io and dart:html.
#1 Best Overall
1. Add the HTTP dependency
From the directory containing pubspec.yaml, run:
flutter pub add http
Then import the package with an alias. The alias makes it obvious which classes and methods belong to the package:
import 'dart:convert';
import 'package:http/http.dart' as http;
The command selects the current compatible package release for your Flutter project. This guide intentionally does not pin a version; run the command in the project you intend to build and commit the resulting dependency lockfile when appropriate.
2. Check platform access before debugging Dart code
The request code can be correct while the app is blocked by platform configuration. The official Flutter guidance calls out these target-specific settings:
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Android
Add the Internet permission to android/app/src/main/AndroidManifest.xml, normally as a child of the <manifest> element and outside <application>:
<uses-permission android:name="android.permission.INTERNET" />
Do not assume this exact edit is required on every target. Android is the platform for which this permission is explicitly called out in the Flutter networking documentation.
macOS
For a macOS app, enable the network client entitlement in the app’s entitlements file:
<key>com.apple.security.network.client</key>
<true/>
Use the entitlements file generated for your macOS target and keep the setting with the target configuration that is actually signed and run. iOS, Windows, Linux, and web have different platform policies; check the platform’s current Flutter and deployment documentation if a request works on one target but not another.
Rank #2
3. Start with a typed model
JSON is untyped text until your program decodes it. A Dart model gives the rest of your app a stable, typed interface and provides one place to validate required fields.
The teaching endpoint in the Flutter cookbook is JSONPlaceholder’s album resource: https://jsonplaceholder.typicode.com/albums/1. A typical response has an integer userId, an integer id, and a string title. Define those fields explicitly:
class Album {
final int userId;
final int id;
final String title;
const Album({
required this.userId,
required this.id,
required this.title,
});
factory Album.fromJson(Map<String, dynamic> json) {
final userId = json['userId'];
final id = json['id'];
final title = json['title'];
if (userId is! int || id is! int || title is! String) {
throw const FormatException('Album response has an unexpected shape');
}
return Album(userId: userId, id: id, title: title);
}
}
The checks are deliberate. If a backend omits a required field, changes a number to a string, or returns an error object where an album was expected, the app should enter its error state rather than display a misleading empty object.
4. Fetch JSON with GET
Keep networking in a function that returns Future<Album>. Parse the URL as a Uri, await the response, check the status code, decode the body, and construct the model:
Future<Album> fetchAlbum({http.Client? client}) async {
final httpClient = client ?? http.Client();
final uri = Uri.parse(
'https://jsonplaceholder.typicode.com/albums/1',
);
try {
final response = await httpClient.get(uri);
if (response.statusCode != 200) {
throw HttpException(
'GET $uri failed with status ${response.statusCode}',
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Expected a JSON object');
}
return Album.fromJson(decoded);
} finally {
if (client == null) {
httpClient.close();
}
}
}
If you use HttpException, import it from dart:io in a mobile or desktop-only layer, or define an application exception that also works for web. The important behavior is the same: a non-success status must become an error rather than being passed to fromJson as if it were valid album data.
The cookbook checks for status 200 for this GET. That number is not universal: follow the API contract for the method you are calling. A server may return another successful status for a different operation, and an API can use a structured error body even when the transport itself completed normally.
5. Keep the future out of build()
Flutter can call build() many times—for example, after a parent rebuilds, a theme changes, or local state updates. Starting the request directly inside build() can therefore issue the same network call repeatedly.
For a one-time load, create and store the future in initState(). Then give that future to FutureBuilder:
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Scan for outdated or missing drivers - takes under a minute3Repair Windows errors before they cause bigger problemsclass AlbumPage extends StatefulWidget {
const AlbumPage({super.key});
@override
State<AlbumPage> createState() => _AlbumPageState();
}
class _AlbumPageState extends State<AlbumPage> {
late Future<Album> _albumFuture;
@override
void initState() {
super.initState();
_albumFuture = fetchAlbum();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Album')),
body: FutureBuilder<Album>(
future: _albumFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Could not load the album: ${snapshot.error}'),
),
);
}
final album = snapshot.data;
if (album == null) {
return const Center(child: Text('The server returned no album.'));
}
return ListTile(
title: Text(album.title),
subtitle: Text('Album ${album.id} · user ${album.userId}'),
);
},
),
);
}
}
What each state means
- Waiting: the future has not completed, so show progress or a skeleton rather than blank content.
- Error: DNS failure, a timeout, a non-success status, malformed JSON, or model validation failure can all arrive here. Give the user a useful retry path in a production screen.
- Data: the model has passed decoding and validation, so widgets can safely use its typed fields.
To refresh intentionally, assign a new future in a button handler and call setState:
void _reload() {
setState(() {
_albumFuture = fetchAlbum();
});
}
This explicit refresh is different from accidentally starting a request during every rebuild.
6. A complete small flow, from URL to widget
The sequence to remember is:
- Represent the endpoint as a parsed
Uri. - Call the method on an asynchronous function.
- Await the response without blocking the UI isolate.
- Check the status code before decoding a success payload.
- Run
jsonDecodefromdart:convert. - Verify the decoded value has the expected JSON shape.
- Construct a typed model and let validation failures become errors.
- Expose the future to
FutureBuilderor another state-management layer.
JSONPlaceholder is an educational sample API. Its album fields and behavior do not establish the schema, authentication, persistence, pagination, rate limits, or status codes of your real service. Replace the URL and model rules with that service’s documentation.
7. Send data with POST
A write request adds a JSON body and a content type. Encode a Dart map with jsonEncode and check the status expected for creation. The Flutter cookbook’s create example checks 201:
Quick wins for a faster PC:
Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Future<Album> createAlbum(String title) async {
final response = await http.post(
Uri.parse('https://example.invalid/albums'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, dynamic>{
'title': title,
'userId': 1,
}),
);
if (response.statusCode != 201) {
throw HttpException(
'POST failed with status ${response.statusCode}',
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Expected a created album object');
}
return Album.fromJson(decoded);
}
The example.invalid host is intentionally not a working service. Substitute your API’s documented creation endpoint, payload fields, and expected response. Some APIs return 200 or 202 for a write; do not copy 201 unless the service specifies it.
8. Update and delete resources
PUT for a replacement
Use PUT when the API defines a replacement or idempotent update. Send the fields required by that API and validate its documented success status:
Rank #4
Future<Album> replaceAlbum(int id, String title) async {
final response = await http.put(
Uri.parse('https://api.example.invalid/albums/$id'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, dynamic>{
'id': id,
'title': title,
'userId': 1,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException('PUT failed with status ${response.statusCode}');
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Expected an updated album object');
}
return Album.fromJson(decoded);
}
DELETE for removal
DELETE responses frequently contain no JSON body. Check the status first and decode only if the API promises a response payload:
Future<void> deleteAlbum(int id) async {
final response = await http.delete(
Uri.parse('https://api.example.invalid/albums/$id'),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException('DELETE failed with status ${response.statusCode}');
}
}
Method semantics and status codes belong to the server contract. A successful DELETE may be represented by different 2xx codes, and a server may return a body that your application should record or display.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →9. Add authentication without guessing the scheme
Protected services normally expect credentials in an Authorization header, but the scheme is chosen by the service. Read its documentation and send exactly the required form. A bearer-token example looks like this:
Future<http.Response> fetchPrivateResource(String token) {
return http.get(
Uri.parse('https://api.example.invalid/private-resource'),
headers: <String, String>{
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
);
}
The token above is a variable, not a credential. Never commit a real secret to source control or print it in an exception. Some services use API keys, signed requests, cookies, or another scheme; do not substitute bearer authentication unless the API says to use it.
10. Make failures understandable and recoverable
A production request can fail at several layers. Treat each layer as a separate diagnostic problem:
- Transport failure: no connection, DNS failure, certificate problem, or a device that is offline. Catch the exception and offer retry.
- HTTP failure: the server responded with 4xx or 5xx. Preserve the status code and, when safe, parse the service’s documented error body.
- Decode failure: the body is not valid JSON or is not the type you expected.
- Validation failure: JSON is valid but required fields have the wrong types or are missing.
- Application failure: the response is valid but cannot be used, such as an expired session or a business-rule rejection.
Use a timeout when an operation should not wait indefinitely:
Free tools Windows power users keep installed
One-click scans. No signup required.
final response = await http
.get(uri)
.timeout(const Duration(seconds: 15));
Choose the duration for the user action and service behavior rather than treating 15 seconds as a universal value. A timeout should become a recoverable state, not an uncaught exception that leaves a spinner running forever.
Best Value
11. Parse large JSON responses without janking the UI
Do not start with isolate complexity for a small object such as one album. Flutter’s background-parsing recipe addresses expensive parsing with an example containing 5,000 photo objects and explains that work taking more than 16 milliseconds can cause visible frame jank. That is an instructional threshold in the recipe, not a guarantee about every device or payload.
When profiling shows decoding is expensive, move the CPU-heavy conversion to another isolate with compute:
import 'dart:convert';
import 'package:flutter/foundation.dart';
List<Album> parseAlbums(String body) {
final decoded = jsonDecode(body);
if (decoded is! List<dynamic>) {
throw const FormatException('Expected a JSON array');
}
return decoded.map((item) {
if (item is! Map<String, dynamic>) {
throw const FormatException('Expected an album object');
}
return Album.fromJson(item);
}).toList();
}
Future<List<Album>> decodeAlbumsInBackground(String body) {
return compute(parseAlbums, body);
}
The function passed to compute should be a top-level or otherwise isolate-safe function, and its input and output must be transferable. Measure before adding this complexity; a small response is usually clearer and fast enough on the main isolate.
12. Inject an HTTP client so tests do not use the network
Passing an http.Client into the fetch function separates request logic from the concrete network implementation. The default can create and close its own client, while a test can provide a mock client and deterministic response.
Future<Album> fetchAlbumWithClient(http.Client client) async {
final response = await client.get(
Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),
);
if (response.statusCode != 200) {
throw HttpException('Unexpected status ${response.statusCode}');
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Expected a JSON object');
}
return Album.fromJson(decoded);
}
A unit test can then verify success, a non-200 response, malformed JSON, and a missing field without depending on an Internet connection. Flutter’s cookbook uses client injection as the basis for mocking request code. Keep the client’s lifetime clear: a long-lived client can be owned by a repository, while a short-lived client should be closed after use.
13. Decide what belongs in a larger app
The direct function-and-FutureBuilder approach is enough for a small screen. As the app grows, introduce structure only to solve a real problem:
- Repository layer: centralizes endpoints, headers, model conversion, and error mapping.
- Environment configuration: keeps development, staging, and production base URLs out of widget code.
- Retries: retry transient failures carefully, with a limit and backoff; do not blindly repeat non-idempotent writes.
- Caching: avoids refetching data that does not need to be fresh on every screen visit.
- Pagination: follows the API’s cursor or page contract instead of loading an unbounded list.
- Generated serialization: can reduce repetitive model code when many response types exist, but it is not required for the first request.
These are architectural options, not prerequisites for connecting one REST endpoint. Keep the first working path observable and tested before adding abstractions.
14. Troubleshoot the common symptoms
| Symptom | Likely cause | What to check |
|---|---|---|
| Android reports a network error immediately | Internet permission is absent from the manifest | Confirm <uses-permission android:name="android.permission.INTERNET" /> is outside <application>, then rebuild. |
| macOS blocks the request | Network client entitlement is missing | Enable com.apple.security.network.client in the signed macOS target’s entitlements. |
| The request repeats whenever the screen changes | The future is created in build() |
Store it in initState or trigger a new future only from an explicit refresh action. |
| A non-JSON error page reaches the model | Status was not checked before decoding | Reject unexpected status codes first and log the status, not sensitive headers. |
jsonDecode succeeds but the model throws |
The payload shape differs from the documented schema | Inspect the decoded type and required keys; update the model only when the API contract has changed. |
| The UI freezes while loading a large list | Parsing is expensive on the main isolate | Profile the operation and consider compute for the parsing step. |
| Tests depend on an Internet connection | The function constructs its own client internally | Accept an injected http.Client and provide a mock in tests. |
15. A beginner’s implementation checklist
- Run
flutter pub add httpand import it ashttp. - Confirm the target platform’s network configuration, especially Android permission and macOS entitlement requirements.
- Use
Uri.parsefor the endpoint rather than assembling an unchecked string throughout the UI. - Keep the request in a
Future-returning function. - Await the response and check the status code required by that operation.
- Decode with
jsonDecodeand validate the top-level JSON type. - Convert the payload into a typed Dart model with required-field checks.
- Create one-time futures in
initState, not inbuild. - Render loading, success, empty, and error states intentionally.
- For writes, set the documented content type, encode the body, and check the operation’s expected 2xx status.
- For protected endpoints, follow the service’s exact authorization scheme and protect credentials.
- Inject an
http.Clientwhen you need deterministic tests. - Move parsing to
computeonly when response size and profiling justify it.
What to remember
A reliable Flutter REST integration is a small, repeatable pipeline: dependency, platform access, URI, asynchronous request, status validation, JSON decoding, typed model, and explicit UI states. The JSONPlaceholder album demonstrates the mechanics, but your real API documentation remains authoritative for URLs, fields, authentication, pagination, limits, and response codes. Start with the simple typed flow, then add retries, caching, repositories, background parsing, or generated serialization only when the app’s measured needs call for them.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

