← Articles

REST API integration in Flutter: a clean approach

By Charlin Joe · 13 August 2025

REST API integration is one of those things that looks obvious until you've debugged a production issue where the retry logic ran twice, the token refreshed in a race condition, and the error your users saw was "Something went wrong" with no context. Here is an approach that holds up.

Package choice: Dio vs http

http is simpler and has fewer dependencies. Dio adds interceptors, request cancellation, form data, and download progress out of the box. For apps with auth tokens, retry logic, and multiple endpoints, Dio saves you from reinventing those features.

Setting up Dio with interceptors

import 'package:dio/dio.dart';

class ApiClient {
  ApiClient({
    required String baseUrl,
    required TokenStorage tokenStorage,
  }) : _dio = Dio(BaseOptions(
          baseUrl: baseUrl,
          connectTimeout: const Duration(seconds: 10),
          receiveTimeout: const Duration(seconds: 30),
          headers: {'Accept': 'application/json'},
        )) {
    _dio.interceptors.addAll([
      _AuthInterceptor(tokenStorage, _dio),
      _LoggingInterceptor(),
    ]);
  }

  final Dio _dio;

  Future<T> get<T>(String path, {Map<String, dynamic>? query}) async {
    final response = await _dio.get<T>(path, queryParameters: query);
    return response.data as T;
  }

  Future<T> post<T>(String path, Map<String, dynamic> body) async {
    final response = await _dio.post<T>(path, data: body);
    return response.data as T;
  }

  Future<T> patch<T>(String path, Map<String, dynamic> body) async {
    final response = await _dio.patch<T>(path, data: body);
    return response.data as T;
  }

  Future<void> delete(String path) => _dio.delete<void>(path);
}

Auth interceptor with token refresh

The tricky part: if two requests fail with 401 simultaneously, you should refresh the token once and retry both — not refresh twice:

class _AuthInterceptor extends Interceptor {
  _AuthInterceptor(this._storage, this._dio);

  final TokenStorage _storage;
  final Dio _dio;
  bool _refreshing = false;
  final _queue = <(RequestOptions, ErrorInterceptorHandler)>[];

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final token = await _storage.readAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode != 401) return handler.next(err);

    if (_refreshing) {
      _queue.add((err.requestOptions, handler));
      return;
    }

    _refreshing = true;
    try {
      final newToken = await _refreshToken();
      await _storage.writeAccessToken(newToken);

      // Retry the original request
      final retried = await _retry(err.requestOptions, newToken);
      handler.resolve(retried);

      // Retry queued requests
      for (final (options, qHandler) in _queue) {
        final retried = await _retry(options, newToken);
        qHandler.resolve(retried);
      }
    } catch (_) {
      // Refresh failed — log out the user
      await _storage.clearTokens();
      handler.next(err);
    } finally {
      _queue.clear();
      _refreshing = false;
    }
  }

  Future<String> _refreshToken() async {
    final refresh = await _storage.readRefreshToken();
    final response = await Dio().post(
      '${_dio.options.baseUrl}/auth/refresh',
      data: {'refresh_token': refresh},
    );
    return response.data['access_token'] as String;
  }

  Future<Response> _retry(RequestOptions original, String token) {
    return _dio.fetch(original..headers['Authorization'] = 'Bearer $token');
  }
}

Typed response models

Never pass raw Map<String, dynamic> through your app. Parse at the boundary:

@freezed
class Order with _$Order {
  const factory Order({
    required String id,
    required String status,
    required double totalAmount,
    required DateTime createdAt,
    required List<OrderItem> items,
  }) = _Order;

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

// In the API client:
Future<List<Order>> getOrders() async {
  final data = await get<List<dynamic>>('/orders');
  return data.map((j) => Order.fromJson(j as Map<String, dynamic>)).toList();
}

Pagination

@freezed
class PaginatedResponse<T> with _$PaginatedResponse<T> {
  const factory PaginatedResponse({
    required List<T> items,
    required int total,
    required int page,
    required int pageSize,
    required bool hasMore,
  }) = _PaginatedResponse<T>;
}

Future<PaginatedResponse<Order>> getOrdersPaginated({
  int page = 1,
  int pageSize = 20,
}) async {
  final data = await get<Map<String, dynamic>>(
    '/orders',
    query: {'page': page, 'per_page': pageSize},
  );
  return PaginatedResponse(
    items: (data['items'] as List)
        .map((j) => Order.fromJson(j as Map<String, dynamic>))
        .toList(),
    total: data['total'] as int,
    page: page,
    pageSize: pageSize,
    hasMore: (data['items'] as List).length == pageSize,
  );
}

Error mapping

Convert HTTP errors to typed domain errors:

sealed class ApiError implements Exception {
  const ApiError(this.message);
  final String message;
}

final class NotFoundError extends ApiError {
  const NotFoundError(String resource) : super('$resource not found');
}

final class NetworkError extends ApiError {
  const NetworkError() : super('No internet connection');
}

final class ServerError extends ApiError {
  const ServerError(int code) : super('Server error ($code)');
}

// Wrap all calls:
Future<T> safeCall<T>(Future<T> Function() call) async {
  try {
    return await call();
  } on DioException catch (e) {
    switch (e.type) {
      case DioExceptionType.connectionError:
        throw const NetworkError();
      case DioExceptionType.badResponse:
        final code = e.response?.statusCode ?? 0;
        if (code == 404) throw const NotFoundError('Resource');
        throw ServerError(code);
      default:
        rethrow;
    }
  }
}

Testing

Mock Dio's HTTP adapter for unit tests — no real network needed:

test('getOrders returns parsed orders', () async {
  final mockAdapter = MockHttpClientAdapter();
  final dio = Dio()..httpClientAdapter = mockAdapter;

  when(mockAdapter.fetch(any, any, any)).thenAnswer((_) async =>
    ResponseBody.fromString(
      '[{"id":"1","status":"pending","total_amount":99.0}]',
      200,
      headers: {Headers.contentTypeHeader: ['application/json']},
    ));

  final client = ApiClient(dio: dio);
  final orders = await client.getOrders();

  expect(orders, hasLength(1));
  expect(orders.first.id, '1');
});

Common pitfalls

Global Dio instance with shared interceptors. If your auth interceptor modifies headers on a shared instance while a refresh is happening, concurrent requests can get the wrong token. Use a lock or the queue pattern above.

Not cancelling requests on widget disposal. Create a CancelToken per request and cancel it in dispose. Otherwise requests complete after the widget is gone and try to call setState on an unmounted widget.

Parsing in the UI layer. JSON parsing on the main thread blocks frames. For large responses, parse in a separate isolate: compute(Order.fromJson, jsonString).

Sign in to like, dislike, or report.

REST API integration in Flutter: a clean approach — ANN Tech