Flutter REST API Integration: A Practical, Production-Ready Guide

End-to-end Flutter REST API integration: http vs Dio, auth, error handling, caching, pagination, testing, security, and best practices.

ASOasis
8 min read
Flutter REST API Integration: A Practical, Production-Ready Guide

Image used for representation purposes only.

Overview

Integrating a REST API is one of the most common tasks in Flutter apps. This guide walks you through a pragmatic, end-to-end approach: choosing an HTTP client, structuring your code, handling JSON, adding authentication, managing errors and retries, enabling caching and offline support, integrating with state management, generating boilerplate, and testing.

Prerequisites

  • Familiarity with Dart null safety and async/await
  • Flutter SDK installed
  • Comfort with basic package management (pubspec.yaml)

Pick your HTTP client: http vs dio

  • package:http: Lightweight, dependency-free, ideal for simple apps and learning. You’ll implement features (timeouts, interceptors, retries) manually or via small helpers.
  • dio: Full-featured client with interceptors, cancelation tokens, FormData, file uploads, timeouts, transformers, and ecosystem plugins (e.g., caching, retry). Great for production apps that need flexibility.

Tip: Start with dio for rich features. Use http if you want minimalism or a very small APK footprint.

Project setup

flutter create flutter_rest_demo
cd flutter_rest_demo
# Add one of these (or both if you want to compare)
flutter pub add dio
flutter pub add http
# Optional utilities
flutter pub add json_annotation
flutter pub add flutter_secure_storage
flutter pub add connectivity_plus
flutter pub add logger
flutter pub add freezed_annotation retrofit
flutter pub add build_runner freezed json_serializable --dev

Folder structure

lib/
  core/
    http/
      api_client.dart
      interceptors.dart
    error/
      failures.dart
  features/
    posts/
      data/
        models/
        sources/
        repositories/
      presentation/
        widgets/
        providers/

This keeps HTTP concerns centralized and features modular.

Making requests with http

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

class HttpApiClient {
  HttpApiClient({required this.baseUrl, http.Client? client})
      : _client = client ?? http.Client();

  final String baseUrl;
  final http.Client _client;

  Future<List<dynamic>> getPosts({int page = 1}) async {
    final uri = Uri.parse('$baseUrl/posts?page=$page');
    final resp = await _client.get(uri).timeout(const Duration(seconds: 10));
    _throwIfNotOk(resp);
    return jsonDecode(resp.body) as List<dynamic>;
  }

  Future<Map<String, dynamic>> createPost(Map<String, dynamic> payload) async {
    final uri = Uri.parse('$baseUrl/posts');
    final resp = await _client.post(
      uri,
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(payload),
    );
    _throwIfNotOk(resp, expected: 201);
    return jsonDecode(resp.body) as Map<String, dynamic>;
  }

  void _throwIfNotOk(http.Response r, {int expected = 200}) {
    if (r.statusCode != expected) {
      throw HttpException('${r.statusCode}: ${r.body}');
    }
  }
}

class HttpException implements Exception {
  final String message;
  HttpException(this.message);
  @override
  String toString() => message;
}

Making requests with dio

import 'package:dio/dio.dart';

class DioApiClient {
  DioApiClient({required String baseUrl, List<Interceptor>? interceptors})
      : _dio = Dio(BaseOptions(
          baseUrl: baseUrl,
          connectTimeout: const Duration(seconds: 7),
          receiveTimeout: const Duration(seconds: 15),
          headers: {'Accept': 'application/json'},
        )) {
    _dio.interceptors.addAll(interceptors ?? []);
  }

  final Dio _dio;

  Future<List<dynamic>> getPosts({int page = 1}) async {
    final resp = await _dio.get('/posts', queryParameters: {'page': page});
    return resp.data as List<dynamic>;
  }

  Future<Map<String, dynamic>> createPost(Map<String, dynamic> payload) async {
    final resp = await _dio.post('/posts', data: payload);
    return Map<String, dynamic>.from(resp.data);
  }
}

JSON models and serialization

Avoid manual Map<String, dynamic> transformations. Use json_serializable or pair with freezed for immutable models and copyWith.

import 'package:json_annotation/json_annotation.dart';
part 'post.g.dart';

@JsonSerializable()
class Post {
  final String id;
  final String title;
  final String body;
  final DateTime createdAt;

  Post({required this.id, required this.title, required this.body, required this.createdAt});

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
  Map<String, dynamic> toJson() => _$PostToJson(this);
}

Generate code:

flutter pub run build_runner build --delete-conflicting-outputs

Authentication

  • Bearer tokens (JWT or opaque): Attach Authorization: Bearer <token> header to each request.
  • API keys: Prefer headers over query params. Rotate and scope keys.
  • OAuth 2.0 / PKCE with an external auth provider: Use a dedicated package or platform SDK; securely store tokens and refresh when expired.

Dio auth interceptor example:

class AuthInterceptor extends Interceptor {
  AuthInterceptor(this._tokenProvider);
  final Future<String?> Function() _tokenProvider;

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

Secure storage tip:

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
final storage = FlutterSecureStorage();
await storage.write(key: 'access_token', value: token);
final token = await storage.read(key: 'access_token');

Never commit secrets to source control; inject at runtime via –dart-define or remote config.

Error handling, retries, and timeouts

  • Classify errors: network issues, timeouts, 4xx (client), 5xx (server), deserialization.
  • Show actionable messages to users; log diagnostic details to Crashlytics/Sentry.
  • Implement exponential backoff for idempotent requests only (GET, sometimes PUT/DELETE depending on semantics).

Dio retry with backoff:

class RetryInterceptor extends Interceptor {
  RetryInterceptor(this._dio, {this.retries = 3});
  final Dio _dio;
  final int retries;

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    final shouldRetry = _isRetryable(err) && err.requestOptions.extra['retries'] != retries;
    if (!shouldRetry) return handler.next(err);

    final attempt = (err.requestOptions.extra['retries'] ?? 0) + 1;
    final delay = Duration(milliseconds: 200 * (1 << (attempt - 1)));
    await Future.delayed(delay);

    final opts = err.requestOptions..extra['retries'] = attempt;
    try {
      final response = await _dio.fetch(opts);
      return handler.resolve(response);
    } catch (e) {
      return handler.next(err);
    }
  }

  bool _isRetryable(DioException e) {
    return e.type == DioExceptionType.connectionTimeout ||
           e.type == DioExceptionType.receiveTimeout ||
           e.response?.statusCode == 503 ||
           e.response?.statusCode == 502;
  }
}

Logging and interceptors

Use structured logs with redaction for tokens.

import 'package:logger/logger.dart';
class LoggingInterceptor extends Interceptor {
  final _log = Logger();
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    _log.i('➡️ ${options.method} ${options.uri}');
    super.onRequest(options, handler);
  }
  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    _log.i('✅ ${response.statusCode} ${response.requestOptions.uri}');
    super.onResponse(response, handler);
  }
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    _log.e('❌ ${err.message} for ${err.requestOptions.uri}');
    super.onError(err, handler);
  }
}

Pagination patterns

  • Offset-based: ?page=2&limit=20 (simple, but can skip/duplicate data if list changes rapidly).
  • Cursor-based: ?cursor=abc123&limit=20 (more reliable for real-time feeds).

Example with dio:

Future<({List<Post> items, String? nextCursor})> fetchPage(String? cursor) async {
  final resp = await _dio.get('/posts', queryParameters: {
    if (cursor != null) 'cursor': cursor,
    'limit': 20,
  });
  final items = (resp.data['items'] as List).map((e) => Post.fromJson(e)).toList();
  return (items: items, nextCursor: resp.data['nextCursor'] as String?);
}

Caching and performance

  • Respect HTTP caching headers: ETag and If-None-Match, Last-Modified and If-Modified-Since.
  • Add a local cache (Hive/Sqflite) for expensive or immutable endpoints.
  • Memoize in-memory results per session to avoid duplicate network calls.

Manual ETag flow with http:

Future<String> getWithEtag(Uri uri, {String? etag}) async {
  final headers = {if (etag != null) 'If-None-Match': etag};
  final resp = await http.get(uri, headers: headers);
  if (resp.statusCode == 304) return '/* use cached body */';
  // Save resp.headers['etag'] and resp.body to cache
  return resp.body;
}

Offline-first and connectivity

  • Listen to connectivity changes with connectivity_plus, but always handle failures gracefully even if “online.”
  • Queue write operations offline and replay when back online.
  • Show optimistic UI for creates/likes; reconcile on failure.

SSL pinning (advanced)

Pin the server’s certificate or public key to mitigate MITM attacks. Rotate pins before expiry.

import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';

Dio createPinnedDio(String baseUrl, List<String> allowedSpkiSha256) {
  final dio = Dio(BaseOptions(baseUrl: baseUrl));
  (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
    final client = HttpClient();
    client.badCertificateCallback = (cert, host, port) {
      final der = cert.der;
      final spkiHash = sha256OfSpki(der); // Implement via crypto package
      return allowedSpkiSha256.contains(spkiHash);
    };
    return client;
  };
  return dio;
}

State management integration (Provider/Riverpod/BLoC)

Example with Riverpod:

final dioProvider = Provider<Dio>((ref) {
  final dio = Dio(BaseOptions(baseUrl: const String.fromEnvironment('API_BASE_URL')));
  dio.interceptors.addAll([AuthInterceptor(() async => ref.read(tokenProvider)), LoggingInterceptor()]);
  return dio;
});

final postsRepoProvider = Provider<PostsRepository>((ref) => PostsRepository(ref.read(dioProvider)));

final postsFutureProvider = FutureProvider.autoDispose((ref) async {
  final repo = ref.read(postsRepoProvider);
  return repo.fetchPosts();
});

Code generation with Retrofit + Freezed

Retrofit interface:

import 'package:retrofit/retrofit.dart';
import 'package:dio/dio.dart';
part 'posts_api.g.dart';

@RestApi(baseUrl: "/")
abstract class PostsApi {
  factory PostsApi(Dio dio, {String baseUrl}) = _PostsApi;

  @GET("/posts")
  Future<List<Post>> getPosts(@Query('page') int page);

  @POST("/posts")
  Future<Post> create(@Body() PostCreateRequest body);
}

Freezed model:

import 'package:freezed_annotation/freezed_annotation.dart';
part 'post.freezed.dart';
part 'post.g.dart';

@freezed
class Post with _$Post {
  const factory Post({
    required String id,
    required String title,
    required String body,
    required DateTime createdAt,
  }) = _Post;

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

Build:

flutter pub run build_runner build -d

File upload and download

Future<void> uploadImage(String path) async {
  final form = FormData.fromMap({
    'file': await MultipartFile.fromFile(path, filename: 'photo.jpg'),
  });
  await _dio.post('/upload', data: form, options: Options(contentType: 'multipart/form-data'));
}

Future<void> downloadFile(String url, String savePath, CancelToken token) async {
  await _dio.download(url, savePath, cancelToken: token, onReceiveProgress: (r, t) {
    final percent = (r / (t == 0 ? 1 : t) * 100).toStringAsFixed(0);
    // update UI progress
  });
}

Testing and mocking

  • Unit-test serialization separately from networking.
  • Mock HTTP responses to make tests deterministic.

http with MockClient:

import 'package:http/testing.dart';
import 'package:test/test.dart';

void main() {
  test('getPosts returns list', () async {
    final mock = MockClient((request) async {
      return http.Response('[{"id":"1","title":"A","body":"B","createdAt":"2024-01-01T00:00:00Z"}]', 200,
        headers: {'content-type': 'application/json'});
    });
    final api = HttpApiClient(baseUrl: 'https://example.com', client: mock);
    final posts = await api.getPosts();
    expect(posts, isA<List<dynamic>>());
  });
}

Dio with adapters (conceptual):

  • Use a mock adapter to stub onGet('/posts') with a payload.
  • Assert interceptors add headers and that retries occur on 503.

CI/CD and environment config

  • Inject base URLs and feature flags via --dart-define:
flutter run --dart-define=API_BASE_URL=https://api.example.com
  • Use build flavors (dev/stage/prod) and keep secrets out of source code.
  • Automate linters, tests, and codegen in CI before packaging.

Common pitfalls and how to avoid them

  • Mixing UI and networking code: keep API logic in repositories/providers.
  • Ignoring cancellation: cancel in-flight requests on widget dispose where appropriate (Dio’s CancelToken).
  • Blocking the UI thread: avoid heavy JSON parsing on the main isolate for huge payloads; consider compute.
  • Poor error mapping: translate low-level exceptions into domain-specific failures for the UI.
  • Inconsistent models: centralize models and codegen to avoid drift.
  • Forgetting timeouts: always set connect/receive/send timeouts.

Security checklist

  • Use HTTPS everywhere; consider SSL pinning for sensitive apps.
  • Store tokens in flutter_secure_storage, not SharedPreferences.
  • Refresh tokens securely; handle 401 by refreshing once, then logout on failure.
  • Redact PII and tokens in logs and crash reports.

Performance checklist

  • Batch requests where possible.
  • Use query parameters to filter/limit server responses.
  • Cache static/rarely changing endpoints.
  • Paginate lists; prefetch next pages on scroll.

Minimal end-to-end example (Dio)

final dio = Dio(BaseOptions(baseUrl: const String.fromEnvironment('API_BASE_URL')))
  ..interceptors.addAll([
    AuthInterceptor(() async => storage.read(key: 'access_token')),
    LoggingInterceptor(),
    RetryInterceptor(dio),
  ]);

final api = DioApiClient(baseUrl: '', interceptors: dio.interceptors);

Future<void> loadFirstPage() async {
  try {
    final items = await api.getPosts(page: 1);
    // map to models, update state
  } on DioException catch (e) {
    // show friendly message based on e.type / e.response?.statusCode
  }
}

Conclusion

A robust Flutter REST integration balances ergonomics, reliability, performance, and security. Choose the right client, model your data with code generation, centralize cross-cutting concerns via interceptors, handle errors intentionally, and back everything with tests. With these building blocks, you can evolve from a quick prototype to a production-ready API layer that’s maintainable and resilient.

Related Posts