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.
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:
ETagandIf-None-Match,Last-ModifiedandIf-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
Pros and Cons of Flutter
A comprehensive exploration of Flutter’s key advantages and trade-offs for cross-platform development.
Flutter 3.29.3 Stable Patch Released: Key Fixes and Developer Insights
An in-depth look at the Flutter 3.29.3 hotfix release, detailing bug fixes, performance improvements, and what they mean for developers.
Flutter vs React Native: In-Depth Performance & Ecosystem Comparison
An extensive analysis of Flutter and React Native across performance benchmarks, ecosystem maturity, and developer experience.