Retrofit in Flutter with Dio & JSON Serializable

Retrofit in Flutter with Dio & JSON Serializable

FlutterPulse

This article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!🚀

A Step-by-Step Guide for Beginners:

A Step-by-Step Guide for Beginners:

If you're building apps in Flutter, chances are you'll need to call APIs, parse JSON, and manage network states. Instead of writing repetitive boilerplate code, we can supercharge our networking layer using:Retrofit: define APIs with clean annotations

  • Dio: powerful HTTP requests + interceptors
  • JSON Serializable: for model generation
  • Provider: state management

In this guide, we'll create a simple Post API client using jsonplaceholder.typicode.com.

Step 1: Add Dependencies

In your pubspec.yaml add

dependencies:
dio: ^5.0.0
retrofit: ^4.0.0
json_annotation: ^4.9.0
provider: ^6.0.5

dev_dependencies:
retrofit_generator: ^8.0.0
json_serializable: ^6.7.1
build_runner: ^2.4.8

Run:

flutter pub get

Step 2: Create the Model with JSON Serializable

Instead of writing 'fromJson' and 'toJson' manually, we'll generate them.

import 'package:json_annotation/json_annotation.dart';

part 'postModel.g.dart';

@JsonSerializable()
class Post {
final int userId;
final int id;
final String title;
final String body;

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

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

Then Run:

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

This creates 'PostModel.g.dart' automatically, like this.

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'postModel.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

Post _$PostFromJson(Map<String, dynamic> json) => Post(
userId: (json['userId'] as num).toInt(),
id: (json['id'] as num).toInt(),
title: json['title'] as String,
body: json['body'] as String,
);

Map<String, dynamic> _$PostToJson(Post instance) => <String, dynamic>{
'userId': instance.userId,
'id': instance.id,
'title': instance.title,
'body': instance.body,
};

How JSON Serializable Works?

When the API returns JSON, we need Dart objects. JSON Serializable automates this.

  1. Annotate your model with @JsonSerializable()
  2. Run build_runner → generates fromJson & toJson code.
  3. Retrofit automatically converts JSON into Dart objects using Post.fr0mJson().

Benefits:

  • No manual parsing
  • Fewer bugs
  • Easy to update if API changes

Step 3: Define API with Retrofit

Retrofit lets us declare APIs using annotations, and generates network code automatically:

import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
import '../model/postModel.dart';

part 'rest_client.g.dart';

@RestApi(baseUrl: "https://jsonplaceholder.typicode.com")
abstract class RestClient {
factory RestClient(Dio dio, {String baseUrl}) = _RestClient;

@GET("/posts")
Future<List<Post>> getPosts();

@GET("/posts/{id}")
Future<Post> getPost(@Path("id") int id);

@POST("/posts")
Future<Post> createPost(@Body() Post post);

@PUT("/posts/{id}")
Future<Post> updatePost(@Path("id") int id, @Body() Post post);

@PATCH("/posts/{id}")
Future<Post> patchPost(@Path("id") int id, @Body() Map<String, dynamic> fields);

@DELETE("/posts/{id}")
Future<void> deletePost(@Path("id") int id);

@GET("/posts")
Future<List<Post>> getPostsByUser(@Query("userId") int userId);

@GET("/posts")
Future<List<Post>> getPostsWithAuth(@Header("Authorization") String token);
}

Generate code again, Run this command:

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

Now, 'rest_client.g.dart' generate automatically like this, & will handle the Dio requests for you 🎉

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'rest_client.dart';

// **************************************************************************
// RetrofitGenerator
// **************************************************************************

// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element

class _RestClient implements RestClient {
_RestClient(
this._dio, {
this.baseUrl,
this.errorLogger,
}) {
baseUrl ??= 'https://jsonplaceholder.typicode.com';
}

final Dio _dio;

String? baseUrl;

final ParseErrorLogger? errorLogger;

@override
Future<List<Post>> getPosts() async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
const Map<String, dynamic>? _data = null;
final _options = _setStreamType<List<Post>>(Options(
method: 'GET',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/posts',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
)));
final _result = await _dio.fetch<List<dynamic>>(_options);
late List<Post> _value;
try {
_value = _result.data!
.map((dynamic i) => Post.fromJson(i as Map<String, dynamic>))
.toList();
} on Object catch (e, s) {
errorLogger?.logError(e, s, _options);
rethrow;
}
return _value;
}

@override
Future<Post> getPost(int id) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
const Map<String, dynamic>? _data = null;
final _options = _setStreamType<Post>(Options(
method: 'GET',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/posts/${id}',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
)));
final _result = await _dio.fetch<Map<String, dynamic>>(_options);
late Post _value;
try {
_value = Post.fromJson(_result.data!);
} on Object catch (e, s) {
errorLogger?.logError(e, s, _options);
rethrow;
}
return _value;
}

@override
Future<Post> createPost(Post post) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
final _data = <String, dynamic>{};
_data.addAll(post.toJson());
final _options = _setStreamType<Post>(Options(
method: 'POST',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/posts',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
)));
final _result = await _dio.fetch<Map<String, dynamic>>(_options);
late Post _value;
try {
_value = Post.fromJson(_result.data!);
} on Object catch (e, s) {
errorLogger?.logError(e, s, _options);
rethrow;
}
return _value;
}

@override
Future<Post> updatePost(
int id,
Post post,
) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
final _data = <String, dynamic>{};
_data.addAll(post.toJson());
final _options = _setStreamType<Post>(Options(
method: 'PUT',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/posts/${id}',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
)));
final _result = await _dio.fetch<Map<String, dynamic>>(_options);
late Post _value;
try {
_value = Post.fromJson(_result.data!);
} on Object catch (e, s) {
errorLogger?.logError(e, s, _options);
rethrow;
}
return _value;
}

@override
Future<Post> patchPost(
int id,
Map<String, dynamic> fields,
) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
final _data = <String, dynamic>{};
_data.addAll(fields);
final _options = _setStreamType<Post>(Options(
method: 'PATCH',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/posts/${id}',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
)));
final _result = await _dio.fetch<Map<String, dynamic>>(_options);
late Post _value;
try {
_value = Post.fromJson(_result.data!);
} on Object catch (e, s) {
errorLogger?.logError(e, s, _options);
rethrow;
}
return _value;
}

@override
Future<void> deletePost(int id) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
const Map<String, dynamic>? _data = null;
final _options = _setStreamType<void>(Options(
method: 'DELETE',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/posts/${id}',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
)));
await _dio.fetch<void>(_options);
}

@override
Future<List<Post>> getPostsByUser(int userId) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{r'userId': userId};
final _headers = <String, dynamic>{};
const Map<String, dynamic>? _data = null;
final _options = _setStreamType<List<Post>>(Options(
method: 'GET',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/posts',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
)));
final _result = await _dio.fetch<List<dynamic>>(_options);
late List<Post> _value;
try {
_value = _result.data!
.map((dynamic i) => Post.fromJson(i as Map<String, dynamic>))
.toList();
} on Object catch (e, s) {
errorLogger?.logError(e, s, _options);
rethrow;
}
return _value;
}

@override
Future<List<Post>> getPostsWithAuth(String token) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{r'Authorization': token};
_headers.removeWhere((k, v) => v == null);
const Map<String, dynamic>? _data = null;
final _options = _setStreamType<List<Post>>(Options(
method: 'GET',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/posts',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
)));
final _result = await _dio.fetch<List<dynamic>>(_options);
late List<Post> _value;
try {
_value = _result.data!
.map((dynamic i) => Post.fromJson(i as Map<String, dynamic>))
.toList();
} on Object catch (e, s) {
errorLogger?.logError(e, s, _options);
rethrow;
}
return _value;
}

RequestOptions _setStreamType<T>(RequestOptions requestOptions) {
if (T != dynamic &&
!(requestOptions.responseType == ResponseType.bytes ||
requestOptions.responseType == ResponseType.stream)) {
if (T == String) {
requestOptions.responseType = ResponseType.plain;
} else {
requestOptions.responseType = ResponseType.json;
}
}
return requestOptions;
}

String _combineBaseUrls(
String dioBaseUrl,
String? baseUrl,
) {
if (baseUrl == null || baseUrl.trim().isEmpty) {
return dioBaseUrl;
}

final url = Uri.parse(baseUrl);

if (url.isAbsolute) {
return url.toString();
}

return Uri.parse(dioBaseUrl).resolveUri(url).toString();
}
}

How Retrofit Works?

Retrofit is like a smart shortcut for Dio.

Instead of writing HTTP logic manually:

  • You define API endpoints using @ GET, @POST, etc.
  • Retrofit generates _RestClient that uses Dio.
  • When you call getPost() , it sends the request and parses the response automatically.

Benefits:

  • Less boilerplate
  • Easy to read & maintain
  • Type-safe responses ('List<Post>' instead of 'dynamic')

Step 4: Setup Dio with Interceptors

Think of interceptors as middleware for your network requests. They let you intercept requests before they are sent, responses before they are processed, and errors when they happen.

  • On Request: Modify headers, add tokens, or log requests.
  • On Response: Log response data, transform it, or handle special cases.
  • On Error: Retry requests, show user-friendly messages, or log errors.

Example:

import 'package:dio/dio.dart';

class DioClient {
static Dio createDio() {
final dio = Dio(BaseOptions(
baseUrl: "https://jsonplaceholder.typicode.com/posts/1",
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));

dio.interceptors.addAll([
LogInterceptor(
request: true,
requestBody: true,
responseBody: true,
error: true,
),
InterceptorsWrapper(
onRequest: (options, handler) {
options.headers["Content-Type"] = "application/json";
return handler.next(options);
},
onError: (DioException e, handler) {
if (e.response?.statusCode == 401) {
}
return handler.next(e);
},
),
]);

return dio;
}
}

How it works internally:

  1. You call an API method via Retrofit.
  2. Request interceptor runs first- adds headers, logs request.
  3. The request hits the server.
  4. Response interceptor runs- logs data, transforms if needed.
  5. If an error occurs, Error interceptor handles it.

Step 5: Use Provider for State Management

Simple 'PostProvider' to fetch posts:

class PostProvider with ChangeNotifier {
final RestClient client = RestClient(Dio());

List<Post> _posts = [];
bool _loading = false;

List<Post> get posts => _posts;
bool get loading => _loading;

Future<void> fetchPosts() async {
_loading = true;
notifyListeners();

try {
_posts = await client.getPosts();
} catch (e) {
print("Error fetching posts: $e");
}

_loading = false;
notifyListeners();
}
}

Step 6: Display in UI

class PostScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = Provider.of<PostProvider>(context);

return Scaffold(
appBar: AppBar(title: Text("Posts")),
body: provider.loading
? Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: provider.posts.length,
itemBuilder: (context, index) {
final post = provider.posts[index];
return ListTile(
title: Text(post.title),
subtitle: Text(post.body),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => provider.fetchPosts(),
child: Icon(Icons.refresh),
),
);
}
}

Here we coverd how Retrofit work, with dio, interceptor & json Serializeable, in 6 step.!

How Everything Works Together:

  1. Call API via Retrofit → 'client.getPosts()'
  2. Dio interceptor runs → logs request, adds headers.
  3. Request hits the server.
  4. Response interceptor runs → logs response.
  5. JSON response is converted to Dart objects-post.fromJson().
  6. Provider updates the UI with these objects.

Conclusion

By combining Retrofit + Dio + Interceptors + JSON Serializable + Provider, we get a clean, scalable, and maintainable networking layer:

  • Clean API interface
  • Automatic JSON parsing
  • Centralized logging & error handling
  • Easy state management

This approach saves time and reduces bugs in any Flutter project that works with APIs.

I am open to freelance, full-time, or part-time roles, feel free to reach out at Linkedin, thank you!

Report Page