Flutter REST API Integration with Riverpod and Dio: Complete Tutorial Copy title

Flutter REST API Integration with Riverpod and Dio

A practical, step-by-step guide to networking, state management, JSON parsing, loading states, error handling, and clean architecture.

Beginner to Intermediate

Last updated: October 2026 · Tutorial · Flutter · Dart · REST API

Almost every modern mobile application communicates with a backend. Whether you are building a banking application, shopping app, social platform, or dashboard, learning how to integrate REST APIs correctly is an essential Flutter development skill.

In this tutorial, we will build a complete example using Flutter, Dio, and Riverpod. We will fetch users from a REST API, convert JSON responses into Dart objects, manage asynchronous states, and display the results in a clean user interface.

What you will build
  • A REST API client using Dio.
  • A Dart model for API response data.
  • Riverpod-based asynchronous state management.
  • A responsive user list screen.
  • Loading, error, retry, and success states.

Table of Contents

  1. Why use Dio and Riverpod?
  2. Understand the architecture
  3. Set up your Flutter project
  4. Create the Dart model
  5. Configure the Dio API client
  6. Fetch API data with Riverpod
  7. Build the user interface
  8. Handle loading, errors, and retry
  9. Test the API integration
  10. Best practices
  11. Common problems and solutions
  12. Frequently asked questions

1. Why Use Dio and Riverpod in Flutter?

Dio and Riverpod solve two different problems and work well together.

🌐

Dio

An HTTP client for sending requests, adding headers, configuring timeouts, using interceptors, and handling network errors.

⚡

Riverpod

A state-management solution that exposes asynchronous data and helps the UI react to loading, success, and error states.

Using these libraries together keeps networking logic separate from UI code. It also makes your application easier to test and maintain.

2. Understand the Flutter API Architecture

Before writing code, let's understand how data travels from the backend to the screen.

Flutter UI
ConsumerWidget
↓
Riverpod Provider
Manages asynchronous state
↓
Dio HTTP Client
Sends the GET request
↓
REST API Server
Returns JSON data
The response travels back through the data layer and provider, and Riverpod updates the UI.

Request flow: UI → Riverpod → Dio → REST API → JSON response → Dart model → UI.

This example uses a small architecture suitable for a tutorial. In a larger production app, you can introduce a repository and separate data, domain, and presentation layers.

3. Set Up Your Flutter Project

Create a new Flutter project:

flutter create flutter_api_demo
cd flutter_api_demo

Add Dio and Riverpod using Flutter's package manager:

flutter pub add dio
flutter pub add flutter_riverpod

These commands install the compatible package versions resolved by your Flutter project. Check the current package documentation if your project requires specific version constraints.

Suggested project structure

lib/
 ├── main.dart
 ├── models/
 │    └── user.dart
 ├── services/
 │    └── api_client.dart
 ├── providers/
 │    └── users_provider.dart
 └── screens/
      └── users_screen.dart

Each file has one primary responsibility. This makes the example easier to understand and extend.

4. Create a Dart Model for the API Response

For this tutorial, we will use JSONPlaceholder's sample users API. It returns a list of users with fields such as name, email, phone, and website.

Create lib/models/user.dart:

class User {
  final int id;
  final String name;
  final String email;
  final String phone;
  final String website;

  const User({
    required this.id,
    required this.name,
    required this.email,
    required this.phone,
    required this.website,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
      email: json['email'] as String,
      phone: json['phone'] as String,
      website: json['website'] as String,
    );
  }
}

How does JSON parsing work?

The API returns JSON, but Flutter UI code works with Dart objects. The User.fromJson() factory constructor converts each JSON object into a strongly typed User instance.

Example transformation

JSON response:

{
  "id": 1,
  "name": "Leanne Graham",
  "email": "leanne@example.com",
  "phone": "123-456-7890",
  "website": "example.com"
}

The factory converts the response into a User object, allowing you to access properties such as user.name and user.email.

Production tip: Real APIs can return null values or change their response format. Validate required fields and consider generated serialization with packages such as json_serializable for larger models.

5. Configure Dio for REST API Requests

Instead of creating a new HTTP client for every request, configure a reusable Dio instance. This lets you centralize base URLs, timeouts, headers, and interceptors.

Create lib/services/api_client.dart:

import 'package:dio/dio.dart';

class ApiClient {
  ApiClient()
      : dio = Dio(
          BaseOptions(
            baseUrl: 'https://jsonplaceholder.typicode.com',
            connectTimeout: const Duration(seconds: 10),
            receiveTimeout: const Duration(seconds: 10),
            sendTimeout: const Duration(seconds: 10),
            headers: {
              'Accept': 'application/json',
            },
          ),
        );

  final Dio dio;
}

Why configure timeouts?

  • Connect timeout: Limits the time allowed to establish a connection.
  • Receive timeout: Limits waiting for response data between network events.
  • Send timeout: Limits waiting while sending request data.

Appropriate timeouts help prevent requests from waiting indefinitely. Choose values based on your API and application requirements.

6. Fetch API Data Using Riverpod

Now we connect Dio with Riverpod. A FutureProvider is a straightforward way to fetch asynchronous data and expose loading, data, and error states to the UI.

Create lib/providers/users_provider.dart:

import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../models/user.dart';
import '../services/api_client.dart';

final apiClientProvider = Provider<ApiClient>((ref) {
  return ApiClient();
});

final usersProvider = FutureProvider<List<User>>((ref) async {
  final apiClient = ref.watch(apiClientProvider);

  final response = await apiClient.dio.get<dynamic>('/users');

  final data = response.data as List<dynamic>;

  return data.map((item) {
    return User.fromJson(item as Map<String, dynamic>);
  }).toList();
});

What happens in this provider?

  1. Riverpod obtains the configured API client.
  2. Dio sends a GET request to /users.
  3. The API returns a JSON array.
  4. Each JSON object is converted to a Dart User.
  5. The provider exposes the resulting list or an error.

Using ref.watch() establishes a reactive dependency. Riverpod can rebuild dependents when the watched provider changes.

7. Build the Flutter User Interface

Let's display the API response as a list of cards containing each user's name, email, phone number, and website.

Create lib/screens/users_screen.dart:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../models/user.dart';
import '../providers/users_provider.dart';

class UsersScreen extends ConsumerWidget {
  const UsersScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final usersAsync = ref.watch(usersProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter API Users'),
        centerTitle: true,
      ),
      body: usersAsync.when(
        loading: () => const Center(
          child: CircularProgressIndicator(),
        ),
        error: (error, stackTrace) => Center(
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Icon(
                  Icons.cloud_off,
                  size: 52,
                  color: Colors.redAccent,
                ),
                const SizedBox(height: 12),
                const Text(
                  'Unable to load users.',
                  style: TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: 12),
                ElevatedButton(
                  onPressed: () {
                    ref.invalidate(usersProvider);
                  },
                  child: const Text('Try Again'),
                ),
              ],
            ),
          ),
        ),
        data: (users) {
          if (users.isEmpty) {
            return const Center(
              child: Text('No users found.'),
            );
          }

          return RefreshIndicator(
            onRefresh: () async {
              await ref.refresh(usersProvider.future);
            },
            child: ListView.builder(
              physics: const AlwaysScrollableScrollPhysics(),
              padding: const EdgeInsets.all(16),
              itemCount: users.length,
              itemBuilder: (context, index) {
                return UserCard(user: users[index]);
              },
            ),
          );
        },
      ),
    );
  }
}

class UserCard extends StatelessWidget {
  const UserCard({
    super.key,
    required this.user,
  });

  final User user;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(14),
      ),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            CircleAvatar(
              backgroundColor: Colors.indigo.shade100,
              child: Text(
                user.name.isNotEmpty
                    ? user.name[0].toUpperCase()
                    : '?',
                style: const TextStyle(
                  color: Colors.indigo,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    user.name,
                    style: const TextStyle(
                      fontSize: 17,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 6),
                  Text(user.email),
                  const SizedBox(height: 4),
                  Text(user.phone),
                  const SizedBox(height: 4),
                  Text(user.website),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

The UI uses usersAsync.when() to handle the three primary asynchronous states without manually maintaining loading and error booleans.

Connect everything in main.dart

Replace the contents of lib/main.dart with:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'screens/users_screen.dart';

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter REST API Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.indigo,
        ),
        useMaterial3: true,
      ),
      home: const UsersScreen(),
    );
  }
}
Run the application
flutter pub get
flutter run

8. Understand Loading, Success, and Error States

A user-friendly application should never leave users wondering whether a request is working. Each state needs clear feedback.

⏳

Loading

Show progress while the request is pending.

✓

Success

Display the returned data in a readable layout.

!

Error

Explain the problem and provide a retry action.

Why is retry important?

Network failures can occur because of poor connectivity, server errors, timeouts, or expired sessions. The retry action in our example invalidates the provider, causing the API request to run again.

Pull-to-refresh is also included through RefreshIndicator, allowing users to manually reload the list.

For a production app, consider distinguishing network failures from authorization errors, server errors, and unexpected response formats. Show a useful message to users while logging diagnostic details safely for developers.

9. How to Test Your API Integration

Testing the API layer helps verify JSON parsing, request behavior, and error handling without depending on a live backend for every test.

For a production codebase, inject the Dio client into your API service or repository so tests can provide a mock client or adapter.

Useful test scenarios include:

  • Valid JSON produces the expected list of users.
  • An empty response produces an empty list.
  • A server error is surfaced to the UI.
  • A timeout does not leave the UI stuck in a loading state.
  • Retry triggers a new request.

You can use Flutter's built-in test framework and a mocking library such as Mockito to test these behaviors. Keep network-dependent integration tests separate from fast unit tests.

10. Best Practices for Flutter REST API Integration

✓ Reuse the HTTP client
Configure one Dio instance with shared options instead of creating a client for every request.
✓ Separate UI from networking
Move HTTP requests and parsing out of widgets into services or repositories.
✓ Use typed models
Convert JSON into Dart objects and validate data at API boundaries.
✓ Configure timeouts
Choose sensible timeout values and handle failures consistently.
✓ Protect credentials
Never embed private backend secrets in a mobile application. Store user tokens securely and enforce authorization on the server.
✓ Test without a live API
Use dependency injection and mocked responses to make tests repeatable.

11. Common Flutter API Problems and Solutions

Problem Possible solution
SocketException Check connectivity, DNS, server availability, and the API URL.
DioException: connection timeout Verify the endpoint and network, then configure an appropriate timeout.
TypeError during JSON parsing Inspect the actual response shape and validate nullable or missing fields.
HTTP 401 or 403 Check authentication, token expiration, permissions, and backend policies.
Flutter web CORS error Configure the server's CORS policy. A Flutter client-side setting cannot replace server authorization.
UI does not refresh Check provider dependencies and invalidate or refresh the correct provider.

12. Frequently Asked Questions

What is the best HTTP client for Flutter?

Dio and the built-in http package are both valid options. Dio is particularly useful when you need interceptors, configurable timeouts, request cancellation, and centralized client configuration.

Why use Riverpod instead of calling Dio inside a widget?

Riverpod separates asynchronous data management from UI rendering. It also supports provider-based dependency injection, reusable state, and testable application logic.

Can I use Riverpod with BLoC or Clean Architecture?

Yes. Riverpod can provide repositories and services within a clean architecture. BLoC and Riverpod are alternative state-management approaches, although they can coexist in a project when there is a clear reason to do so.

How do I add authentication to Dio requests?

You can use a Dio interceptor to attach an access token to authorized requests. Handle token refresh carefully, avoid infinite retry loops, and never log passwords or sensitive tokens.

Does FutureProvider automatically refresh API data?

A FutureProvider caches its resolved state while it remains valid. Use provider invalidation, refresh, or changed dependencies when you need to fetch updated data. Automatic refresh intervals require additional logic.

Conclusion

Integrating REST APIs in Flutter becomes easier when networking, asynchronous state management, and UI rendering have clearly defined responsibilities.

In this tutorial, we created a typed Dart model, configured Dio, fetched users through Riverpod, displayed the response, and handled loading, errors, retry, and pull-to-refresh.

As your application grows, you can extend this foundation with repositories, authentication interceptors, pagination, offline caching, structured error handling, and automated tests.

Enjoyed this Flutter tutorial?

Share it with another Flutter developer and explore more practical tutorials on REST APIs, Riverpod, architecture, and testing.

Explore Flutter Documentation

References

Disclaimer: This article is for educational purposes. Always review package documentation, security requirements, and API contracts before using example code in a production application.

Comments