How to Fetch API Data in Flutter Using HTTP (Beginner Guide)

Learn to fetch data from a REST API in Flutter with the http package: JSON to Dart model classes, FutureBuilder, loading and error states, pull-to-refresh, POST requests and common fixes.

By Yaqoob Developer · · 9 min read

  • #Flutter
  • #API
  • #HTTP
  • #JSON
  • #REST
  • #Beginners

Most real apps load data from the internet: weather, news, products, chat messages. In this beginner guide you will learn how to fetch data from a REST API in Flutter using the official http package, turn the JSON into Dart objects, and show it in a list with loading, error and pull-to-refresh states.

We will use the free JSONPlaceholder test API, so you do not need an account or API key.

In this post you will learn:

  • What an API and JSON are, in simple words
  • How to add the http package and internet permissions
  • How to make a GET request with http.get
  • How to convert JSON into a Dart model class
  • How to show data with FutureBuilder
  • How to handle loading, errors and empty data
  • How to add pull-to-refresh and a details screen
  • Common mistakes and how to fix them

Before you start

You need Flutter installed and a project that runs. If you are new to Flutter, first build your first Flutter app.

Create a new project for this tutorial:

flutter create api_demo
cd api_demo

What is an API and what is JSON?

An API is a web address that returns data instead of a web page. Your app sends a request to that address and receives a response.

Open this link in your browser: https://jsonplaceholder.typicode.com/posts/1. You will see:

{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati...",
  "body": "quia et suscipit\nsuscipit recusandae consequuntur..."
}

That format is JSON: keys in quotes, followed by values. A list of items looks like [ {...}, {...} ]. In this tutorial we will load the full list from https://jsonplaceholder.typicode.com/posts, which returns 100 posts.

The flow in our app:

App → http.get(url) → API server → JSON text → jsonDecode → Post objects → ListView

Flow of data from http.get to the API server, jsonDecode, Post.fromJson and a ListView

Step 1: Add the http package

From your project folder, run:

flutter pub add http

This adds http to your pubspec.yaml. It is the official Dart package for making network requests and is perfect for beginners.

Step 2: Add internet permissions

Android

Debug builds can use the internet automatically, but release builds cannot unless you ask for permission. Many beginners only find out when their published app shows no data.

Open android/app/src/main/AndroidManifest.xml and add this line above the <application> tag:

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

    <application
        ...

macOS desktop

If you run on macOS desktop, open both macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements and add:

<key>com.apple.security.network.client</key>
<true/>

iOS, web and Windows need no changes for HTTPS requests.

Step 3: Create the model class

A model class describes one item from the API in Dart. It turns a JSON map into a real object, so you write post.title instead of json['title'] everywhere, and the compiler can catch typos.

Create lib/models/post.dart:

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;

  /// Creates a Post from one JSON object returned by the API.
  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,
    );
  }
}
  • The factory Post.fromJson constructor reads each key from the JSON map and casts it to the right type.
  • The keys ('id', 'userId', 'title', 'body') must match the API exactly, including upper and lower case.

Step 4: Create the API service

Keep all network code in one place, not inside your widgets. Create lib/services/post_service.dart:

import 'dart:convert';

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

import '../models/post.dart';

class PostService {
  static const _baseUrl = 'https://jsonplaceholder.typicode.com';

  Future<List<Post>> fetchPosts() async {
    final uri = Uri.parse('$_baseUrl/posts');

    final response = await http
        .get(uri, headers: {'Accept': 'application/json'})
        .timeout(const Duration(seconds: 10));

    if (response.statusCode != 200) {
      throw Exception('Server error: ${response.statusCode}');
    }

    final List<dynamic> data = jsonDecode(response.body);
    return data
        .map((item) => Post.fromJson(item as Map<String, dynamic>))
        .toList();
  }
}

Line by line:

  • import 'package:http/http.dart' as http; loads the package with the prefix http, so calls read clearly as http.get(...).
  • Uri.parse(...) turns the address string into a Uri, which http.get requires.
  • await http.get(uri) sends the request and waits for the response without freezing the app.
  • .timeout(...) stops waiting after 10 seconds, so a bad connection does not leave users staring at a spinner forever.
  • response.statusCode tells you whether it worked. 200 means OK. 404 means not found. 500 means a server error.
  • jsonDecode(response.body) turns the JSON text into Dart lists and maps. It comes from dart:convert, which is built in.
  • .map(...).toList() converts every JSON map into a Post object.

Step 5: Show the data with FutureBuilder

FutureBuilder is a widget that rebuilds itself when a Future finishes. It gives you a snapshot with three possible situations: still loading, finished with an error, or finished with data.

Create lib/screens/posts_screen.dart:

import 'package:flutter/material.dart';

import '../models/post.dart';
import '../services/post_service.dart';
import 'post_detail_screen.dart';

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

  @override
  State<PostsScreen> createState() => _PostsScreenState();
}

class _PostsScreenState extends State<PostsScreen> {
  final _service = PostService();
  late Future<List<Post>> _postsFuture;

  @override
  void initState() {
    super.initState();
    _postsFuture = _service.fetchPosts();
  }

  Future<void> _refresh() async {
    final future = _service.fetchPosts();
    setState(() => _postsFuture = future);
    try {
      await future;
    } catch (_) {
      // The error is shown by the FutureBuilder below.
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Posts')),
      body: FutureBuilder<List<Post>>(
        future: _postsFuture,
        builder: (context, snapshot) {
          // 1. Still loading
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }

          // 2. Something went wrong
          if (snapshot.hasError) {
            return Center(
              child: Padding(
                padding: const EdgeInsets.all(24),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Icon(Icons.wifi_off, size: 48),
                    const SizedBox(height: 12),
                    const Text(
                      'Could not load posts.\nCheck your internet connection.',
                      textAlign: TextAlign.center,
                    ),
                    const SizedBox(height: 16),
                    FilledButton(
                      onPressed: _refresh,
                      child: const Text('Try again'),
                    ),
                  ],
                ),
              ),
            );
          }

          // 3. Data arrived
          final posts = snapshot.data ?? [];
          if (posts.isEmpty) {
            return const Center(child: Text('No posts found.'));
          }

          return RefreshIndicator(
            onRefresh: _refresh,
            child: ListView.separated(
              itemCount: posts.length,
              separatorBuilder: (_, _) => const Divider(height: 1),
              itemBuilder: (context, index) {
                final post = posts[index];
                return ListTile(
                  leading: CircleAvatar(child: Text('${post.id}')),
                  title: Text(
                    post.title,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                  subtitle: Text(
                    post.body,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                  ),
                  onTap: () => Navigator.of(context).push(
                    MaterialPageRoute(
                      builder: (_) => PostDetailScreen(post: post),
                    ),
                  ),
                );
              },
            ),
          );
        },
      ),
    );
  }
}

Why the Future is created in initState

This is the most important rule of FutureBuilder: create the Future once, in initState, and store it in a variable.

// ❌ Wrong: a new request is sent every time the widget rebuilds
FutureBuilder(future: _service.fetchPosts(), ...)

// ✅ Right: the request is sent once and the result is reused
FutureBuilder(future: _postsFuture, ...)

If you call fetchPosts() directly inside build, every rebuild (rotating the phone, opening the keyboard, any setState) sends a brand-new request and shows the spinner again.

How pull-to-refresh works

RefreshIndicator shows the spinning circle when the user pulls the list down and calls _refresh. That function creates a new Future, stores it with setState so FutureBuilder listens to the new request, and waits until it finishes so the spinner hides at the right time.

Step 6: The details screen

Create lib/screens/post_detail_screen.dart:

import 'package:flutter/material.dart';

import '../models/post.dart';

class PostDetailScreen extends StatelessWidget {
  const PostDetailScreen({super.key, required this.post});

  final Post post;

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;

    return Scaffold(
      appBar: AppBar(title: Text('Post #${post.id}')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(post.title, style: textTheme.headlineSmall),
            const SizedBox(height: 8),
            Text('By user ${post.userId}', style: textTheme.labelLarge),
            const SizedBox(height: 20),
            Text(post.body, style: textTheme.bodyLarge),
          ],
        ),
      ),
    );
  }
}

We pass the Post object through the constructor, so the details screen does not need another network request.

Step 7: Update main.dart

Replace lib/main.dart with:

import 'package:flutter/material.dart';

import 'screens/posts_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'API Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
      home: const PostsScreen(),
    );
  }
}

Your final folder structure:

lib/
├── main.dart
├── models/
│   └── post.dart
├── services/
│   └── post_service.dart
└── screens/
    ├── posts_screen.dart
    └── post_detail_screen.dart

Run the app:

flutter run

You will see a spinner for a moment, then a list of 100 posts. Tap a post to open it, and pull the list down to refresh. To test the error state, turn on airplane mode and tap Try again.

The posts list, the post details screen and the error state with a Try again button

Sending data with a POST request

Fetching data uses GET. To send data (for example, creating a new post), use http.post with a JSON body:

Future<Post> createPost(String title, String body) async {
  final response = await http.post(
    Uri.parse('$_baseUrl/posts'),
    headers: {'Content-Type': 'application/json; charset=UTF-8'},
    body: jsonEncode({'title': title, 'body': body, 'userId': 1}),
  );

  if (response.statusCode != 201) {
    throw Exception('Failed to create post: ${response.statusCode}');
  }
  return Post.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
  • jsonEncode turns a Dart map into JSON text.
  • The Content-Type header tells the server you are sending JSON.
  • A successful create usually returns status 201 (Created) instead of 200.

JSONPlaceholder is a fake API: it returns a success response but does not really save your post.

Common mistakes and how to fix them

The app works in debug but shows no data in the release APK

You forgot the INTERNET permission in android/app/src/main/AndroidManifest.xml. See Step 2.

"type 'List<dynamic>' is not a subtype of type 'Map<String, dynamic>'"

The API returned a list ([...]), but your code treats it as a single object ({...}), or the other way round. Open the URL in a browser: if it starts with [, decode it as a List; if it starts with {, decode it as a Map.

"type 'Null' is not a subtype of type 'String'"

A key is missing or spelled differently from the API (for example 'userid' instead of 'userId'). Check the exact key names. If a value can really be missing, make the field nullable: final String? subtitle; and read it with json['subtitle'] as String?.

The spinner shows again every time the screen rebuilds

You are creating the Future inside build. Move it to initState, as explained in Step 5.

"Connection refused" when calling a local server on the Android emulator

Inside the Android emulator, localhost means the emulator itself, not your computer. Use http://10.0.2.2:PORT to reach a server running on your computer. Also note that Android blocks plain http:// (non-HTTPS) requests by default in release builds.

"XMLHttpRequest error" on Flutter web

The API does not allow requests from browsers on other domains (this is called CORS). It is a server setting, not a Flutter bug. Test the same code on Android or iOS, or ask the API owner to enable CORS.

What to learn next

You can now load real data from the internet in Flutter. Good next steps:

  • Real APIs: try a free API like a weather or movie API. The steps are the same: read the JSON, write a model, call http.get.
  • Firebase instead of your own API: connect your Flutter app to Firebase and use Cloud Firestore for real-time data.
  • Better project structure: as your app grows, move models and services into proper layers. See Flutter Clean Architecture and folder structure.
  • State management: learn Provider, Riverpod or Bloc to share fetched data across many screens.

Keep reading

More articles

  • · 9 min read

    Flutter Row, Column and Stack Explained with Examples

    Understand Flutter's core layout widgets: main and cross axis, mainAxisAlignment, crossAxisAlignment, Expanded, Flexible, Spacer, Stack and Positioned, plus a complete profile card example.

  • · 7 min read

    Flutter Navigation: How to Move Between Screens

    Learn Flutter navigation step by step: Navigator.push and pop, passing data to a screen, returning results, pushReplacement for login flows, named routes, and when to use go_router.