Build a REST API Call in Flutter with Dio and Riverpod — Complete Guide

Bilal Fali··15 min read
Build a REST API Call in Flutter with Dio and Riverpod — Complete Guide

Build a REST API Call in Flutter with Dio and Riverpod

Every Flutter app that does anything useful talks to an API. The question is not whether you need networking — it is how you structure it so it does not become a tangled mess of try-catch blocks, loading booleans, and state scattered across six different files.

This guide shows you how to build a clean, testable, production-grade API layer in Flutter using Dio for HTTP and Riverpod for state management. By the end, you will have a networking architecture you can drop into any project — from a weekend prototype to a production app with millions of users.

This is the same approach I use in every Flutter project I ship, including Bacify, an AI-powered exam prep app on Google Play.

Flutter REST API architecture with Dio and Riverpod

What Is Dio?

Dio is a powerful HTTP client for Dart that goes far beyond what the built-in http package offers. It handles everything a real-world networking layer needs out of the box.

  • Interceptors — inject auth tokens, log requests, handle token refresh automatically

  • Request cancellation — cancel in-flight requests when the user navigates away

  • Timeout configuration — separate connect, send, and receive timeouts

  • FormData and file uploads — multipart uploads with progress tracking

  • Retry logic — automatic retries with customizable conditions

  • Response transformation — parse JSON into Dart objects at the client level

The http package is fine for a tutorial. Dio is what you use when you are building something real.

What Is Riverpod?

Riverpod is a reactive state management and dependency injection framework for Flutter. In the context of networking, Riverpod solves three problems that every other approach handles poorly.

First, it gives you AsyncValue — a sealed type that represents loading, data, and error states in one object. No more juggling isLoading, hasError, and data as separate variables.

Second, it provides automatic disposal. When a screen is popped and no widget is watching a provider, Riverpod can cancel the HTTP request and dispose of the state. No memory leaks.

Third, it enables dependency injection without context. Your repository can read the Dio provider without needing a BuildContext, which means your business logic stays clean and testable.

Why This Stack Matters in 2026

The Dio + Riverpod combination has become the default networking architecture in the Flutter ecosystem. The Riverpod team officially recommends this pattern. Google's Flutter team references it in conference talks. And the majority of production Flutter apps on the Play Store and App Store that use Riverpod are pairing it with Dio.

The alternative stacks — http + Provider, http + BLoC, or raw Dio without a state management layer — all work, but they require significantly more boilerplate and manual error handling. Dio + Riverpod gives you the most functionality with the least code.

Project Setup

Add these dependencies to your pubspec.yaml:

dependencies:
  flutter_riverpod: ^2.6.1
  riverpod_annotation: ^2.3.5
  dio: ^5.7.0
  freezed_annotation: ^2.4.6
  json_annotation: ^4.9.0

dev_dependencies:
  riverpod_generator: ^2.4.3
  build_runner: ^2.4.13
  freezed: ^2.5.7
  json_serializable: ^6.8.0

Run flutter pub get, then keep the code generator running in watch mode:

dart run build_runner watch --delete-conflicting-outputs

Step-by-Step Implementation

Step 1 — Define Your Data Model

Start with the data. We will use a simple User model that maps to a JSON API response. Freezed generates the fromJson, toJson, copyWith, and equality methods automatically.

// lib/models/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String email,
    @JsonKey(name: 'avatar_url') String? avatarUrl,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

Tip: Always use @JsonKey(name: 'field_name') when the API uses snake_case but your Dart model uses camelCase. This keeps your Dart code idiomatic while handling any JSON format.

Step 2 — Create the Dio Provider

The Dio instance should be a single, globally available provider. This ensures every API call in your app shares the same base configuration, interceptors, and timeout settings.

// lib/core/network/dio_provider.dart
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'dio_provider.g.dart';

@riverpod
Dio dio(DioRef ref) {
  final dio = Dio(
    BaseOptions(
      baseUrl: 'https://api.example.com/v1',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 15),
      sendTimeout: const Duration(seconds: 10),
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
    ),
  );

  dio.interceptors.addAll([
    _AuthInterceptor(ref),
    LogInterceptor(
      requestBody: true,
      responseBody: true,
      error: true,
    ),
  ]);

  return dio;
}
Dio interceptor chain diagram showing request and response flow

Step 3 — Build an Auth Interceptor

Almost every production app needs to attach an auth token to requests and handle 401 responses. Here is an interceptor that does both:

// lib/core/network/auth_interceptor.dart
class _AuthInterceptor extends Interceptor {
  final Ref _ref;

  _AuthInterceptor(this._ref);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    // Read the auth token from your auth provider
    final token = _ref.read(authTokenProvider);

    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }

    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    if (err.response?.statusCode == 401) {
      // Token expired — trigger logout or refresh
      _ref.read(authNotifierProvider.notifier).signOut();
    }

    handler.next(err);
  }
}

Why this matters: Without an interceptor, you end up copy-pasting headers: {'Authorization': 'Bearer $token'} into every single API call. One interceptor handles it globally. One place to change. Zero duplication.

Step 4 — Create a Typed API Exception

Raw DioException is not something you want leaking into your UI layer. Create a clean exception class that translates HTTP errors into user-facing messages:

// lib/core/network/api_exception.dart
class ApiException implements Exception {
  final String message;
  final int? statusCode;

  const ApiException({required this.message, this.statusCode});

  factory ApiException.fromDioException(DioException e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        return const ApiException(
          message: 'Connection timed out. Check your internet.',
        );

      case DioExceptionType.badResponse:
        final statusCode = e.response?.statusCode;
        final body = e.response?.data;
        final serverMessage =
            body is Map ? body['message'] as String? : null;

        return ApiException(
          message: serverMessage ?? _fallbackMessage(statusCode),
          statusCode: statusCode,
        );

      case DioExceptionType.connectionError:
        return const ApiException(message: 'No internet connection.');

      case DioExceptionType.cancel:
        return const ApiException(message: 'Request cancelled.');

      default:
        return const ApiException(message: 'Something went wrong.');
    }
  }

  static String _fallbackMessage(int? code) {
    switch (code) {
      case 400: return 'Bad request.';
      case 401: return 'Unauthorized. Please sign in again.';
      case 403: return 'Access denied.';
      case 404: return 'Resource not found.';
      case 422: return 'Validation error.';
      case 429: return 'Too many requests. Try again later.';
      case 500: return 'Server error. Try again later.';
      default: return 'Unexpected error (code: $code).';
    }
  }

  @override
  String toString() => message;
}

Step 5 — Build the Repository

The repository is the single source of truth for all API calls related to a specific domain. It takes Dio as a dependency, makes the HTTP call, parses the response, and throws typed exceptions on failure.

// lib/features/users/data/user_repository.dart
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'user_repository.g.dart';

@riverpod
UserRepository userRepository(UserRepositoryRef ref) {
  return UserRepository(dio: ref.watch(dioProvider));
}

class UserRepository {
  final Dio _dio;

  const UserRepository({required Dio dio}) : _dio = dio;

  Future<List<User>> getUsers({int page = 1, int limit = 20}) async {
    try {
      final response = await _dio.get(
        '/users',
        queryParameters: {'page': page, 'limit': limit},
      );

      final List<dynamic> data = response.data['data'];
      return data.map((json) => User.fromJson(json)).toList();
    } on DioException catch (e) {
      throw ApiException.fromDioException(e);
    }
  }

  Future<User> getUserById(int id) async {
    try {
      final response = await _dio.get('/users/$id');
      return User.fromJson(response.data['data']);
    } on DioException catch (e) {
      throw ApiException.fromDioException(e);
    }
  }

  Future<User> createUser({
    required String name,
    required String email,
  }) async {
    try {
      final response = await _dio.post(
        '/users',
        data: {'name': name, 'email': email},
      );
      return User.fromJson(response.data['data']);
    } on DioException catch (e) {
      throw ApiException.fromDioException(e);
    }
  }

  Future<void> deleteUser(int id) async {
    try {
      await _dio.delete('/users/$id');
    } on DioException catch (e) {
      throw ApiException.fromDioException(e);
    }
  }
}
Repository pattern diagram showing data flow from API through repository to UI

Step 6 — Create Riverpod Providers for the UI

Now connect the repository to your UI using Riverpod's FutureProvider and NotifierProvider. This is where the magic of AsyncValue kicks in.

// lib/features/users/presentation/providers/users_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'users_provider.g.dart';

// Simple fetch — returns AsyncValue<List<User>> automatically
@riverpod
Future<List<User>> usersList(UsersListRef ref) async {
  final repo = ref.watch(userRepositoryProvider);
  return repo.getUsers();
}

// Single user by ID — family provider
@riverpod
Future<User> userDetail(UserDetailRef ref, int userId) async {
  final repo = ref.watch(userRepositoryProvider);
  return repo.getUserById(userId);
}

// Stateful provider for create/delete actions
@riverpod
class UsersNotifier extends _$UsersNotifier {
  @override
  Future<List<User>> build() async {
    final repo = ref.watch(userRepositoryProvider);
    return repo.getUsers();
  }

  Future<void> addUser({required String name, required String email}) async {
    final repo = ref.read(userRepositoryProvider);

    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      await repo.createUser(name: name, email: email);
      return repo.getUsers();
    });
  }

  Future<void> removeUser(int id) async {
    final repo = ref.read(userRepositoryProvider);

    // Optimistic delete — remove from UI immediately
    final previousState = state;
    state = AsyncData(
      state.valueOrNull?.where((u) => u.id != id).toList() ?? [],
    );

    try {
      await repo.deleteUser(id);
    } catch (e) {
      // Rollback on failure
      state = previousState;
      rethrow;
    }
  }
}

Pro tip: The removeUser method demonstrates optimistic updates — the UI updates instantly and rolls back only if the API call fails. This makes your app feel significantly faster without sacrificing data consistency.

Step 7 — Build the UI

The widget layer is now the simplest part. All the complexity lives in the repository and providers. The UI just watches and reacts.

// lib/features/users/presentation/screens/users_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

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

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

    return Scaffold(
      appBar: AppBar(title: const Text('Users')),
      body: usersAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stack) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(error.toString()),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () => ref.invalidate(usersNotifierProvider),
                child: const Text('Retry'),
              ),
            ],
          ),
        ),
        data: (users) => RefreshIndicator(
          onRefresh: () async => ref.invalidate(usersNotifierProvider),
          child: ListView.builder(
            itemCount: users.length,
            itemBuilder: (context, index) {
              final user = users[index];
              return ListTile(
                leading: CircleAvatar(child: Text(user.name[0])),
                title: Text(user.name),
                subtitle: Text(user.email),
                trailing: IconButton(
                  icon: const Icon(Icons.delete_outline),
                  onPressed: () => ref
                      .read(usersNotifierProvider.notifier)
                      .removeUser(user.id),
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

Notice how clean this widget is. There are no loading booleans, no error flags, no null checks. AsyncValue.when handles every state in a single expression. If you want to understand the difference between ref.watch, ref.read, and ref.listen, the Riverpod guide on bidev.site covers them in detail.

Flutter Users screen showing list with loading, error, and data states

Architecture Overview

Here is how the entire data flow works from API to pixel:

Layer File Responsibility
Network dio_provider.dart Configures Dio, interceptors, base URL, timeouts
Network auth_interceptor.dart Injects auth token, handles 401
Network api_exception.dart Translates DioException into typed, user-facing errors
Data user.dart Data model with JSON serialization (Freezed)
Data user_repository.dart API calls, JSON parsing, exception conversion
State users_provider.dart Riverpod providers exposing AsyncValue to UI
UI users_screen.dart Watches providers, renders loading/error/data

Each layer depends only on the layer directly below it. The UI never touches Dio. The repository never touches widgets. This separation means you can swap Dio for another HTTP client, or swap Riverpod for BLoC, without rewriting the rest of your codebase.

Clean architecture layer diagram for Flutter with Dio and Riverpod

Best Practices

1. Always Use Cancel Tokens for Screen-Level Fetches

When a user navigates away from a screen, the HTTP request should be cancelled — not left running in the background. Riverpod makes this easy with the ref.onDispose callback:

@riverpod
Future<List<User>> usersList(UsersListRef ref) async {
  final cancelToken = CancelToken();
  ref.onDispose(cancelToken.cancel);

  final repo = ref.watch(userRepositoryProvider);
  return repo.getUsers(cancelToken: cancelToken);
}

2. Use ref.invalidate for Pull-to-Refresh

Do not rebuild providers manually. Call ref.invalidate(provider) to force a refetch. Riverpod handles the loading state, cancellation of the old request, and state update automatically.

3. Keep Repositories Stateless

A repository is a function bucket — it takes inputs, makes an API call, returns data. It should never hold state. State belongs in Riverpod providers.

4. Type Your Errors

Never let raw DioException propagate to the UI. The ApiException pattern shown above keeps your error messages consistent and user-friendly across every screen.

5. Use AsyncValue.guard for State Mutations

When performing write operations (create, update, delete), wrap them in AsyncValue.guard to automatically handle the transition between loading, success, and error states:

state = const AsyncLoading();
state = await AsyncValue.guard(() async {
  await repo.createUser(name: name, email: email);
  return repo.getUsers();
});

Common Mistakes

Mistake 1 — Creating a New Dio Instance Per Request

Every Dio instance allocates its own connection pool. Creating one per API call wastes memory and ignores connection reuse. Use a single Dio provider and share it across all repositories.

Mistake 2 — Catching Exceptions in the Widget

If you are writing try-catch inside a widget's build method or onPressed callback, your architecture is leaking. Exceptions should be caught in the repository and surfaced through Riverpod's AsyncValue.error state.

Mistake 3 — Using ref.watch Inside Callbacks

ref.watch must only be called at the top level of a build method. Inside onPressed, onChanged, or any callback, always use ref.read:

// Wrong
onPressed: () {
  final users = ref.watch(usersProvider); // Never do this
}

// Correct
onPressed: () {
  ref.read(usersNotifierProvider.notifier).addUser(name: name, email: email);
}

Mistake 4 — Ignoring Timeout Configuration

The default Dio timeout is infinite. On a slow network, your users will stare at a loading spinner forever. Always set explicit connectTimeout, receiveTimeout, and sendTimeout values in your BaseOptions.

Mistake 5 — Not Handling Offline State

A DioExceptionType.connectionError means the device has no internet. Your ApiException factory should return a clear "No internet connection" message, and your UI should show a retry button — not a generic error.

Dio vs http Package Comparison

Feature Dio http package
Interceptors Built-in, chainable Not supported
Request cancellation CancelToken Manual with Client.close()
Timeout configuration Per-type (connect, send, receive) Single timeout only
File upload with progress Built-in with FormData Requires multipart manually
Retry logic Via dio_smart_retry package Manual implementation
Response transformation Built-in transformer Manual JSON decode
Bundle size impact Slightly larger Minimal
Best for Production apps Simple scripts, learning

Tools and Resources

Tool Purpose Link
Dio HTTP client pub.dev/packages/dio
Riverpod State management + DI riverpod.dev
Freezed Data class generation pub.dev/packages/freezed
json_serializable JSON parsing codegen pub.dev/packages/json_serializable
dio_smart_retry Automatic retry interceptor pub.dev/packages/dio_smart_retry
pretty_dio_logger Clean request/response logging pub.dev/packages/pretty_dio_logger
Postman / Insomnia API testing postman.com

Frequently Asked Questions

Should I use Dio or the http package in Flutter?

Use Dio for any app you intend to ship to production. It provides interceptors, cancellation tokens, granular timeouts, and file upload support that the http package lacks. The http package is fine for learning or throwaway scripts.

Can I use Dio without Riverpod?

Yes. Dio is a standalone HTTP client. You can use it with BLoC, Provider, GetX, or no state management at all. However, Riverpod's AsyncValue and dependency injection make the Dio integration significantly cleaner.

How do I handle token refresh with Dio?

Create a custom interceptor that catches 401 responses, calls your refresh token endpoint, updates the stored token, and retries the original request. The QueuedInterceptorsWrapper class in Dio queues subsequent requests while the refresh is in progress, preventing race conditions.

Is Riverpod better than BLoC for API calls?

Riverpod requires less boilerplate. A FutureProvider replaces an entire BLoC class (event, state, and bloc files) for simple fetch operations. BLoC offers more explicit event-driven architecture that some teams prefer for complex business logic. For pure API integration, Riverpod is more concise.

How do I add pagination with Dio and Riverpod?

Use a NotifierProvider that holds the current page number and accumulated list. Call your repository with incrementing page parameters and append results to the existing list. Trigger the next page load when the user scrolls near the bottom using a ScrollController or the visibility_detector package.

How do I test a repository that uses Dio?

Use Dio's HttpClientAdapter to mock responses in tests. Alternatively, create an interface for your repository and provide a mock implementation via Riverpod's overrideWith in your test's ProviderScope. The second approach is cleaner because it does not require mocking Dio at all.

How do I cache API responses with Dio?

Use the dio_cache_interceptor package. It supports in-memory, Hive, and database-backed caching with configurable TTL, cache-control header support, and forced refresh. Add it as an interceptor in your Dio provider and caching works transparently across all API calls.

Does Dio support GraphQL?

Dio can make GraphQL requests since they are just POST calls with a JSON body. However, for a full GraphQL experience with typed queries, caching, and subscriptions, use the graphql_flutter or ferry package instead.

How do I upload files with Dio in Flutter?

Use Dio's FormData class with MultipartFile.fromFile(). You can track upload progress with the onSendProgress callback on dio.post(), which gives you bytes sent and total bytes for building a progress indicator.

What is the best folder structure for Dio + Riverpod?

Use a feature-first structure: lib/core/network/ for Dio provider, interceptors, and exceptions. Then lib/features/[feature]/data/ for the repository and models, and lib/features/[feature]/presentation/ for providers and screens. This scales cleanly from 3 features to 30.

Conclusion

Building a REST API layer in Flutter does not have to be complicated. With Dio handling the HTTP complexity and Riverpod managing the state, you get a networking architecture that is clean, testable, and scales from a side project to a production app.

The key takeaways are straightforward: use a single Dio provider with interceptors for auth and logging. Wrap API errors in typed exceptions. Keep repositories stateless. Let Riverpod's AsyncValue handle loading, error, and data states so your widgets stay simple.

This pattern works. It is the same architecture powering thousands of production Flutter apps in 2026, and it is the same one I use in every project I build.

For more Flutter guides — from Riverpod deep dives to Firebase integration and clean architecture patterns — visit bidev.xyz.

Final architecture summary showing complete Dio and Riverpod data flow

Suggested Internal Links

Written by Bilal — Flutter developer, creator of Bacify, and founder of bidev.xyz.

Share

Comments

Comments

Leave a comment

0/2000

Comments appear after review.