İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Flutter: HTTP Requests and REST APIs

Ahmet Balaman

9 min read

FlutterHTTPREST APIJSONAsyncFutureBuilder
Flutter: HTTP Requests and REST APIs

The data in a real app almost always comes from a server: a product list, a user profile, messages. In Flutter the simplest tool for the job is the http package published by the Dart team. In this post we build it up step by step: sending GET, POST, PUT and DELETE requests to a REST API, turning the JSON that comes back into model classes, handling status codes and timeouts properly, and finally wiring everything into a screen that shows "loading, error, data" states. The examples use JSONPlaceholder, a fake API, so you can experiment without setting up a real server.

It helps a lot if async, await and Future already feel comfortable. If not, read asynchronous code and error handling in Dart first.

Setup and Permissions

Add the package to your project:

flutter pub add http

Importing it with a prefix is a common habit, so short names like get and post don't clash with your own functions:

import 'package:http/http.dart' as http;

On Android the app needs the INTERNET permission in android/app/src/main/AndroidManifest.xml to reach the network. Flutter's networking docs explicitly ask you to add this line:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <application ...>

If you target macOS, you also need the com.apple.security.network.client key in both DebugProfile.entitlements and Release.entitlements. iOS needs no extra permission, but the address has to be https (more on that in the mistakes section).

Your First GET Request

In its simplest form a request looks like this:

import 'dart:convert';
import 'package:http/http.dart' as http;

Future<void> fetchFirstPost() async {
  final uri = Uri.https('jsonplaceholder.typicode.com', '/posts/1');
  final response = await http.get(uri);

  if (response.statusCode == 200) {
    final json = jsonDecode(response.body) as Map<String, dynamic>;
    print(json['title']);
  } else {
    print('Request failed: ${response.statusCode}');
  }
}

There are three pieces. Uri.https builds the address safely; if you pass query parameters as the third argument ({'userId': '1'}), it encodes special characters for you. http.get returns a Future<Response>; response.statusCode holds the server's status code and response.body holds the body as text. jsonDecode then turns that text into Dart objects: a JSON object becomes a Map<String, dynamic>, a JSON array becomes a List<dynamic>.

From JSON to a Model Class: fromJson and toJson

Working with string keys like json['title'] everywhere means a typo blows up at runtime instead of at compile time. Instead, we convert the data into a model class once and use typed objects in the rest of the app:

class Post {
  const Post({
    required this.id,
    required this.userId,
    required this.title,
    required this.body,
  });

  final int id;
  final int userId;
  final String title;
  final String body;

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'] as int,
      userId: json['userId'] as int,
      title: json['title'] as String,
      body: json['body'] as String,
    );
  }

  Map<String, dynamic> toJson() => {
        'id': id,
        'userId': userId,
        'title': title,
        'body': body,
      };

  Post copyWith({String? title, String? body}) => Post(
        id: id,
        userId: userId,
        title: title ?? this.title,
        body: body ?? this.body,
      );
}

fromJson converts server data into an object, and toJson converts the object into the shape the server expects. copyWith creates a modified copy of an object whose fields are final; it comes in handy for the update request.

One detail to watch: json['id'] as int throws a TypeError if the server unexpectedly sends id as a string. That is an Error, not an Exception, so an on Exception catch block won't catch it. With Dart 3 pattern matching you can check the whole shape at once and produce a meaningful error. The examples in the Flutter docs take this route too:

factory Post.fromJson(Map<String, dynamic> json) {
  return switch (json) {
    {
      'id': int id,
      'userId': int userId,
      'title': String title,
      'body': String body,
    } =>
      Post(id: id, userId: userId, title: title, body: body),
    _ => throw const FormatException('Post JSON has an unexpected shape'),
  };
}

As the number of models and fields grows, writing this by hand gets tedious. At that point it makes sense to move to a code generator such as json_serializable; the idea stays the same, only fromJson and toJson are generated for you.

POST, PUT and DELETE

In REST the operation is expressed by the HTTP method: GET reads, POST creates a new record, PUT replaces an existing record, DELETE removes it. For requests that send a body, don't forget two things: turn the data into text with jsonEncode, and tell the server it is JSON with the Content-Type header.

const headers = {'Content-Type': 'application/json; charset=UTF-8'};

// POST: create a record
final created = await http.post(
  Uri.https('jsonplaceholder.typicode.com', '/posts'),
  headers: headers,
  body: jsonEncode({'userId': 1, 'title': 'Hello', 'body': 'My first post'}),
);
print(created.statusCode); // 201 Created

// PUT: update the record
final updated = await http.put(
  Uri.https('jsonplaceholder.typicode.com', '/posts/${post.id}'),
  headers: headers,
  body: jsonEncode(post.copyWith(title: 'New title').toJson()),
);
print(updated.statusCode); // 200 OK

// DELETE: remove the record
final deleted = await http.delete(
  Uri.https('jsonplaceholder.typicode.com', '/posts/${post.id}'),
);
print(deleted.statusCode); // 200 OK

JSONPlaceholder doesn't actually store these changes; it only responds as if it had. After the POST you get back an object with id: 101, but you won't see it when you fetch the list again. That's ideal for experimenting, just don't mistake it for a bug. http.patch, which changes only some fields of a record, works the same way; the API's documentation tells you which methods it supports.

Status Codes and Error Handling

An HTTP request can fail in two different ways, and telling them apart matters:

  1. The request never reaches the server. There's no internet, the domain can't be resolved, the connection drops. In that case http.get throws, and the http package reports it as an http.ClientException.
  2. The server answers, but the answer is an error. Codes like 404, 401 or 500 do not throw. The response arrives normally; its statusCode just isn't in the 2xx range. Checking it is your job.

The most common beginner mistake is skipping the second case: calling jsonDecode(response.body) without looking at statusCode. A 404 page often returns HTML, and jsonDecode throws a FormatException; or the server's error JSON gets fed into your model and you get an unrelated type error. Here is roughly what the codes you'll see most often mean:

Code Meaning What the app does
200, 201, 204 Success (204 has an empty body) Process the data
400 Bad request Check the data you sent
401, 403 Not authenticated / not allowed Send the user to sign in
404 Resource not found Show "not found"
500, 502, 503 Server-side problem Show "try again later"

Timeouts

http.get and the other functions have no timeout parameter; if the server is slow or the connection hangs, the user stares at a spinner for a long time. The timeout method that every Dart Future has helps here:

final response = await http
    .get(uri)
    .timeout(const Duration(seconds: 10));

When the time runs out, a TimeoutException from dart:async is thrown. You need to catch that too and show the user a clear message; in the next section we put all of this in one place.

Collecting Requests in a Service Class

If every screen writes its own http.get call, error and timeout checks get repeated everywhere. Instead, we gather the requests into one class. If you send several requests to the same server, keeping one http.Client and closing it with close() when you're done, as the package docs recommend, also lets the connection be reused:

import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;

class ApiException implements Exception {
  const ApiException(this.message, {this.statusCode});

  final String message;
  final int? statusCode;

  @override
  String toString() => message;
}

class PostApi {
  PostApi({http.Client? client}) : _client = client ?? http.Client();

  static const _host = 'jsonplaceholder.typicode.com';
  static const _timeout = Duration(seconds: 10);
  static const _headers = {
    'Content-Type': 'application/json; charset=UTF-8',
    'Accept': 'application/json',
  };

  final http.Client _client;

  Future<List<Post>> fetchPosts({int? userId}) async {
    final uri = Uri.https(
      _host,
      '/posts',
      userId == null ? null : {'userId': '$userId'},
    );
    final data = await _send(() => _client.get(uri, headers: _headers));
    return (data as List<dynamic>)
        .map((e) => Post.fromJson(e as Map<String, dynamic>))
        .toList();
  }

  Future<Post> createPost({
    required int userId,
    required String title,
    required String body,
  }) async {
    final data = await _send(
      () => _client.post(
        Uri.https(_host, '/posts'),
        headers: _headers,
        body: jsonEncode({'userId': userId, 'title': title, 'body': body}),
      ),
    );
    return Post.fromJson(data as Map<String, dynamic>);
  }

  Future<Post> updatePost(Post post) async {
    final data = await _send(
      () => _client.put(
        Uri.https(_host, '/posts/${post.id}'),
        headers: _headers,
        body: jsonEncode(post.toJson()),
      ),
    );
    return Post.fromJson(data as Map<String, dynamic>);
  }

  Future<void> deletePost(int id) async {
    await _send(() => _client.delete(Uri.https(_host, '/posts/$id')));
  }

  /// Shared flow: timeout, connection errors, status code and JSON decoding.
  Future<Object?> _send(Future<http.Response> Function() request) async {
    final http.Response response;
    try {
      response = await request().timeout(_timeout);
    } on TimeoutException {
      throw const ApiException('The server did not respond in time.');
    } on http.ClientException {
      throw const ApiException('Could not connect. Check your internet.');
    }

    final code = response.statusCode;
    if (code == 401) {
      throw const ApiException('Your session has expired.', statusCode: 401);
    }
    if (code == 404) {
      throw const ApiException('Record not found.', statusCode: 404);
    }
    if (code < 200 || code >= 300) {
      throw ApiException('Server error ($code).', statusCode: code);
    }

    if (response.bodyBytes.isEmpty) return null; // e.g. 204 No Content
    try {
      return jsonDecode(utf8.decode(response.bodyBytes));
    } on FormatException {
      throw const ApiException('The server sent an unexpected response.');
    }
  }

  void close() => _client.close();
}

This class gives you three wins. Screens now only deal with ApiException; whether it was a timeout, a connection problem or a 404 is decided in one place. Because the http.Client comes in through the constructor, tests can pass MockClient from package:http/testing.dart instead of hitting a real server. And decoding the body with utf8.decode(response.bodyBytes) guarantees that non-ASCII characters display correctly even when the server doesn't declare a charset.

Mini Scenario: A Posts Screen

Now let's connect the service to a screen. On open it loads the user's posts, shows a spinner while loading, shows a message and a "Try again" button on error, and the button in the bottom right sends a new post:

import 'package:flutter/material.dart';

class PostsPage extends StatefulWidget {
  const PostsPage({super.key});

  @override
  State<PostsPage> createState() => _PostsPageState();
}

class _PostsPageState extends State<PostsPage> {
  final _api = PostApi();
  late Future<List<Post>> _postsFuture;

  @override
  void initState() {
    super.initState();
    _postsFuture = _api.fetchPosts(userId: 1);
  }

  @override
  void dispose() {
    _api.close();
    super.dispose();
  }

  void _reload() {
    setState(() => _postsFuture = _api.fetchPosts(userId: 1));
  }

  Future<void> _addPost() async {
    final messenger = ScaffoldMessenger.of(context);
    try {
      final post = await _api.createPost(
        userId: 1,
        title: 'New post',
        body: 'Sent from Flutter',
      );
      messenger.showSnackBar(
        SnackBar(content: Text('Created, id: ${post.id}')),
      );
    } on ApiException catch (e) {
      messenger.showSnackBar(SnackBar(content: Text(e.message)));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Posts'),
        actions: [
          IconButton(onPressed: _reload, icon: const Icon(Icons.refresh)),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _addPost,
        child: const Icon(Icons.add),
      ),
      body: FutureBuilder<List<Post>>(
        future: _postsFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text('${snapshot.error}'),
                  const SizedBox(height: 12),
                  FilledButton(
                    onPressed: _reload,
                    child: const Text('Try again'),
                  ),
                ],
              ),
            );
          }
          final posts = snapshot.data ?? const <Post>[];
          if (posts.isEmpty) {
            return const Center(child: Text('No posts yet'));
          }
          return ListView.separated(
            itemCount: posts.length,
            separatorBuilder: (context, index) => const Divider(height: 1),
            itemBuilder: (context, index) {
              final post = posts[index];
              return ListTile(
                title: Text(post.title),
                subtitle: Text(
                  post.body,
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
              );
            },
          );
        },
      ),
    );
  }
}

Here are the decisions worth noticing. The Future is created once in initState; if we called _api.fetchPosts() inside build, every rebuild would fire a new request. I cover that mistake in detail in the FutureBuilder post. The error message comes straight from ApiException.toString, so the user sees a readable sentence instead of a stack trace. In _addPost, the ScaffoldMessenger is looked up before the await, so even if the user leaves the page while the request is running, no stale context is used. For the list itself see the ListView post, and for the message the SnackBar post.

If the same data is shared across several screens, or the list should stay up to date after adds and deletes, this FutureBuilder setup isn't enough; moving the service into a ChangeNotifier with Provider or into a provider with Riverpod is the better fit. The PostApi class works unchanged in both cases.

When Does dio Make Sense?

The http package is deliberately small. dio, a popular choice in larger projects, ships some things ready-made: a shared base URL and connection timeout via BaseOptions, interceptors that attach a token to every request, CancelToken for cancelling requests, and progress callbacks for uploads and downloads. It also hands you the decoded JSON in response.data and by default throws a DioException for non-2xx codes.

For an app that sends a handful of requests to one API, http plus a small service class like the one above is enough, and you can see exactly what's happening. Once needs like token refresh, central logging or large file transfers show up, switching to dio is less work than writing all of that by hand. The concepts (method, headers, body, status code) are the same in both packages.

Common Mistakes

1. Sending a Map without jsonEncode

If you write body: {'title': 'Hello'}, http sends it as form data (application/x-www-form-urlencoded), not JSON. The server returns 400 or 415, or sees empty fields. For an API that expects JSON, always send body: jsonEncode(...) together with the 'Content-Type': 'application/json' header.

2. Calling jsonDecode without checking the status code

Symptom: FormatException: Unexpected character (at character 1) <!DOCTYPE html>. The server returned an HTML error page and you treated it as JSON. Check statusCode first, then decode.

3. An app that works in debug but can't reach the internet in release

Symptom: everything works during development, but in the build you ship to the store no request goes through. In the template created by flutter create, the INTERNET permission only exists in the manifests under android/app/src/debug/ and profile/; it's there so hot reload and the debugger can work. The release build uses the manifest in the main folder. Don't forget to add the permission there.

4. Calling localhost from the emulator and using plain http

If you run an API on your own machine (say, a service written with ASP.NET Core Minimal API), localhost inside the Android emulator points to the emulator itself. You reach your computer at 10.0.2.2. Also, Android 9 and later and iOS block unencrypted http:// connections by default. You can relax that during development, but a published app should always use https.

5. Creating the Future inside build

Writing FutureBuilder(future: api.fetchPosts(), ...) sends the same request again every time the keyboard opens or setState is called. Create the Future once in initState and keep it in a field.

Frequently Asked Questions

How do I add a token to requests, and where should I store it?

You send the token in headers on every request as 'Authorization': 'Bearer $token'; with a service class, adding it to the shared headers is enough. SharedPreferences is not the right place to store it, because the data isn't encrypted. I explain what goes where in local data storage with SharedPreferences; the short answer is flutter_secure_storage.

Why does the same request fail with a CORS error on Flutter web?

Flutter web sends the request from inside the browser, and for requests to another domain the browser expects the server to allow it with CORS headers. Mobile has no such check, so the same code can work on a phone and fail on the web. The fix isn't in Flutter but on the server: the API needs a CORS configuration that allows the address your app runs on.

What should I do if the UI stutters while decoding large JSON?

jsonDecode and the model conversion run on the main isolate. With a few hundred records you won't notice, but with very large responses animations can freeze briefly. For that case the Flutter docs recommend moving the parsing to a separate isolate with the compute function (or Isolate.run); the function has to be top-level or static and should take only the body text and return the list of models.

Comments