← Articles

JSON serialization in Flutter with json_serializable

By John · 4 September 2025

REST APIs return JSON. You need to turn that JSON into Dart objects and back. json_serializable generates the boilerplate so you don't write fromJson/toJson by hand.

Setup

dependencies:
  json_annotation: ^4.8.1

dev_dependencies:
  json_serializable: ^6.7.1
  build_runner: ^2.4.8

Annotate your model

import 'package:json_annotation/json_annotation.dart';

part 'product.g.dart';

@JsonSerializable()
class Product {
  final String id;
  final String name;
  final double price;

  // API uses snake_case, Dart uses camelCase
  @JsonKey(name: 'image_url')
  final String imageUrl;

  // Field might be absent in older API versions
  @JsonKey(defaultValue: true)
  final bool isAvailable;

  // Nullable field
  final String? description;

  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.imageUrl,
    required this.isAvailable,
    this.description,
  });

  factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
  Map<String, dynamic> toJson() => _$ProductToJson(this);
}
flutter pub run build_runner build --delete-conflicting-outputs

Global snake_case → camelCase

Instead of @JsonKey(name:) on every field, configure it globally:

@JsonSerializable(fieldRename: FieldRename.snake)
class Product {
  final String id;
  final String name;
  final double price;
  final String imageUrl;   // Automatically maps to 'image_url'
  final bool isAvailable;  // Automatically maps to 'is_available'
  // ...
}

Nested objects

@JsonSerializable()
class Order {
  final String id;
  final User customer;
  final List<OrderItem> items;
  final Address shippingAddress;

  const Order({
    required this.id,
    required this.customer,
    required this.items,
    required this.shippingAddress,
  });

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

// User, OrderItem, Address all need @JsonSerializable() annotations too

Custom converters

For types that need special handling:

class DateTimeConverter implements JsonConverter<DateTime, String> {
  const DateTimeConverter();

  @override
  DateTime fromJson(String json) => DateTime.parse(json);

  @override
  String toJson(DateTime object) => object.toIso8601String();
}

class MoneyConverter implements JsonConverter<Money, int> {
  const MoneyConverter();

  @override
  Money fromJson(int json) => Money.fromCents(json);

  @override
  int toJson(Money object) => object.cents;
}

@JsonSerializable()
class Order {
  @DateTimeConverter()
  final DateTime createdAt;

  @MoneyConverter()
  final Money total;
  // ...
}

Enums

enum OrderStatus { pending, processing, shipped, delivered, cancelled }

// json_serializable 6.x maps 'pending' → OrderStatus.pending automatically
@JsonSerializable()
class Order {
  @JsonKey(unknownEnumValue: OrderStatus.pending) // fallback for unknown values
  final OrderStatus status;
  // ...
}

Handling a list of objects from an API

// API: { "products": [ {...}, {...} ] }
final Map<String, dynamic> response = jsonDecode(responseBody);
final products = (response['products'] as List)
    .map((e) => Product.fromJson(e as Map<String, dynamic>))
    .toList();

// API: [ {...}, {...} ]  (top-level array)
final List<dynamic> response = jsonDecode(responseBody);
final products = response
    .map((e) => Product.fromJson(e as Map<String, dynamic>))
    .toList();

With Dio

future<List<Product>> getProducts() async {
  final response = await dio.get<List<dynamic>>('/products');
  return response.data!
      .map((e) => Product.fromJson(e as Map<String, dynamic>))
      .toList();
}

Future<Order> getOrder(String id) async {
  final response = await dio.get<Map<String, dynamic>>('/orders/$id');
  return Order.fromJson(response.data!);
}

Common pitfalls

Not running build_runner after changing model fields. Changing a field name or type and forgetting to regenerate leaves .g.dart out of sync. The generated file still maps the old name and the new code fails at runtime.

dynamic instead of Map<String, dynamic>. jsonDecode() returns dynamic. Casting immediately to Map<String, dynamic> before passing to fromJson prevents hard-to-debug type errors.

Using int for monetary values. Floating-point arithmetic (double) loses precision with money. Either store prices as integers (cents) and convert at display time, or use a Money type. json_serializable supports both patterns via a custom converter.

Sign in to like, dislike, or report.

JSON serialization in Flutter with json_serializable — ANN Tech