JSON arrives as a string and you want it as a typed object. The bridge in Flutter is two short pieces: jsonDecode from dart:convert, which turns the string into a Map<String, dynamic>, and a model class with a fromJson factory that reads that map into named, typed fields. Add a toJson method for the return trip. This guide covers the map shape, hand-writing fromJson and toJson, nested objects and lists, null safety on fields, and when to switch to generated code, all with paste-ready snippets.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowEvery snippet below is paste-ready against current stable Flutter. The JSON usually comes from a network call, so if you have not wired one yet, the HTTP requests guide shows where response.body comes from.
We'll decode the string, build a model with fromJson, add toJson, handle nesting and lists, lock down null safety, and weigh code generation.
If you'd rather watch models and serialization built into a real screen, the channel covers the full data flow from API to UI.
jsonDecode and the map shape
jsonDecode lives in the built-in dart:convert library — no package to add. It takes a JSON string and returns a Dart value whose static type is dynamic: a Map<String, dynamic> for a JSON object, or a List<dynamic> for an array.
import 'dart:convert';
const body = '{"id": 1, "name": "Ada", "active": true}';
final Map<String, dynamic> data = jsonDecode(body);
print(data['name']); // Ada
The keys are strings; the values are dynamic, so a number could be int or double and a missing key reads as null. Reading raw maps like this is fine for one field, but it scatters string keys through your code. A model class fixes that.
A model with fromJson
Wrap the map in a class. The convention is a fromJson factory that takes the decoded map and returns a typed instance, casting each value to the expected type.
class User {
final int id;
final String name;
final bool active;
const User({required this.id, required this.name, required this.active});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
active: json['active'] as bool,
);
}
final user = User.fromJson(jsonDecode(body));
Now the rest of your app uses user.name, not data['name'] — autocomplete works, typos are caught at compile time, and the JSON shape lives in one place.
toJson for sending
To send the object back — a POST or PUT — add a toJson method that produces a Map<String, dynamic>, then hand it to jsonEncode.
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'active': active,
};
final payload = jsonEncode(user.toJson()); // back to a JSON string
So fromJson reads inbound data and toJson writes outbound data. Keep the key names identical on both sides and the round trip stays lossless.

Model real API responses
The Complete Flutter Guide covers models, serialization, and the full API-to-UI flow inside real, shipped apps.
Enrol nowNested objects and lists
Real payloads nest. A user with an address object and a list of roles maps each nested piece through its own fromJson, and each list element via map.
class Address {
final String city;
const Address({required this.city});
factory Address.fromJson(Map<String, dynamic> json) =>
Address(city: json['city'] as String);
}
class Member {
final String name;
final Address address; // nested object
final List<String> roles; // list of primitives
const Member({required this.name, required this.address, required this.roles});
factory Member.fromJson(Map<String, dynamic> json) => Member(
name: json['name'] as String,
address: Address.fromJson(json['address'] as Map<String, dynamic>),
roles: (json['roles'] as List).map((e) => e as String).toList(),
);
}
For a top-level array of objects, decode then map each element through the model:
final List<User> users = (jsonDecode(body) as List)
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
Null safety on fields
APIs omit fields and send nulls. Make optional fields nullable and supply defaults so a missing key never crashes the parse. The null-aware and null-coalescing operators carry the load.
factory Profile.fromJson(Map<String, dynamic> json) => Profile(
name: json['name'] as String? ?? 'Anonymous', // missing → default
age: json['age'] as int?, // genuinely optional
tags: (json['tags'] as List?)?.cast<String>() ?? const [],
);
Cast with the nullable type (as String?) when a field may be absent, and fall back with ??. Watch the number case too: JSON 1 may decode as int, so for a double field use (json['price'] as num).toDouble() to accept both.
Parse nested objects
The section above showed a nested Address with a single field; real nested objects carry several, and the pattern scales the same way. The rule is simple: a parent never reaches into a child's keys. It casts the child's slice of the map to Map<String, dynamic> and hands it straight to the child's own fromJson. Each class owns exactly the keys it declares.
class Address {
final String street;
final String city;
final String postcode;
const Address({
required this.street,
required this.city,
required this.postcode,
});
factory Address.fromJson(Map<String, dynamic> json) => Address(
street: json['street'] as String,
city: json['city'] as String,
postcode: json['postcode'] as String,
);
Map<String, dynamic> toJson() => {
'street': street,
'city': city,
'postcode': postcode,
};
}
class User {
final int id;
final String name;
final Address address; // nested object
const User({required this.id, required this.name, required this.address});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
address: Address.fromJson(json['address'] as Map<String, dynamic>),
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'address': address.toJson(), // delegate back down
};
}
The as Map<String, dynamic> cast on json['address'] is what makes this safe: jsonDecode returns the nested object as dynamic, and the cast turns it into the exact shape Address.fromJson expects. On the way out, the parent's toJson calls address.toJson() so the nesting is rebuilt symmetrically. Nest three or four levels deep and nothing changes — each layer only knows its own children.
Parse a list field
A list of objects is the case that trips people up, because each element is itself a map that needs its own fromJson. Cast the field to List, map every element through the child factory, and collect with toList().
class Tag {
final String label;
final String colour;
const Tag({required this.label, required this.colour});
factory Tag.fromJson(Map<String, dynamic> json) => Tag(
label: json['label'] as String,
colour: json['colour'] as String,
);
Map<String, dynamic> toJson() => {'label': label, 'colour': colour};
}
class User {
final int id;
final List<Tag> tags; // array of objects
const User({required this.id, required this.tags});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
tags: (json['tags'] as List)
.map((e) => Tag.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> toJson() => {
'id': id,
'tags': tags.map((t) => t.toJson()).toList(),
};
}
Two casts do the work. json['tags'] as List turns the dynamic value into something you can map over, and e as Map<String, dynamic> inside the callback gives each element the shape Tag.fromJson needs. Going outbound, tags.map((t) => t.toJson()).toList() mirrors the read exactly.
Dates, enums, and numbers
JSON has no date, enum, or strict number types, so three field kinds need a small conversion as they cross the boundary. Get these right once and they stop being a source of runtime surprises.
Dates. A timestamp arrives as an ISO-8601 string; parse it with DateTime.parse and write it back with toIso8601String.
enum Status { active, suspended, unknown }
Status statusFromName(String? raw) => Status.values.firstWhere(
(s) => s.name == raw,
orElse: () => Status.unknown, // safe fallback for new/unknown values
);
class Account {
final DateTime createdAt;
final Status status;
final double price;
const Account({
required this.createdAt,
required this.status,
required this.price,
});
factory Account.fromJson(Map<String, dynamic> json) => Account(
createdAt: DateTime.parse(json['createdAt'] as String),
status: statusFromName(json['status'] as String?),
price: (json['price'] as num).toDouble(), // accepts 1 or 1.0
);
Map<String, dynamic> toJson() => {
'createdAt': createdAt.toIso8601String(),
'status': status.name,
'price': price,
};
}
Enums. Parse by name with firstWhere and always pass an orElse. An API that adds a new status value should not crash an old build, so an unknown fallback keeps the parse alive. Writing back, status.name gives the string.
Numbers. This is the quiet trap. JSON makes no distinction between 1 and 1.0, so a value the API treats as a price can decode as an int. Casting json['price'] as double then throws on a plain 1. Cast to num first and call .toDouble() — num is the common supertype of int and double, so it accepts either and normalises to the type you want.
Parse defensively: nulls and missing keys
A strict cast trusts the payload completely. json['name'] as String reads a missing key as null, and casting null to the non-nullable String throws a runtime TypeError — the parse dies on the first absent field. Production APIs omit keys, send null, and add fields between releases, so cast through a nullable type and supply a default.
factory User.fromJson(Map<String, dynamic> json) => User(
// missing or null → fall back, never throw
name: json['name'] as String? ?? '',
age: json['age'] as int?, // genuinely optional
active: json['active'] as bool? ?? false,
score: (json['score'] as num?)?.toDouble() ?? 0, // nullable + number trap
tags: (json['tags'] as List?)
?.map((e) => e as String)
.toList() ??
const <String>[],
);
The shape is always the same: cast with the nullable type (as String?, as List?), then decide what a missing value means. Use ?? '' or ?? false when the field is required by your UI but the server might drop it, and keep the field genuinely nullable (int? age) when "absent" is a real state your code handles. The number trap reappears here — (json['score'] as num?)?.toDouble() stays null-safe and accepts both int and double. A model that never throws on a slightly-off payload is the difference between a blank list and a crashed screen.
Generate it with json_serializable
Hand-written serialization is clear for small models, but maintaining dozens of fields by hand invites typos and drift. For large or frequently changing models, generate the code with json_serializable. It needs one runtime dependency for the annotations and two dev dependencies for the build.
dependencies:
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.8.0
Annotate the class with @JsonSerializable(), add the part directive pointing at the file the generator will create, and wire fromJson / toJson to the generated _$UserFromJson and _$UserToJson functions. The part filename must match the source file: user.dart declares part 'user.g.dart';.
// user.dart
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart'; // matches this file's name
@JsonSerializable()
class User {
final int id;
final String name;
User({required this.id, required this.name});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
Run the builder from the project root. The --delete-conflicting-outputs flag clears stale generated files so the build does not stall on a leftover .g.dart:
dart run build_runner build --delete-conflicting-outputs
That writes user.g.dart with the boilerplate filled in. Re-run the command after every change to an annotated model, or use dart run build_runner watch to regenerate on save. Nested classes and lists generate automatically as long as each nested type is itself @JsonSerializable(). The trade-off is a build step and an extra dependency, so reserve it for when the model count makes hand-writing painful.
Common mistakes
- Reading raw maps everywhere. Wrap JSON in a model so the app stays typed.
- Casting a nullable field as non-null. Use
as String?and a??default for optional keys. - Assuming int for numbers. Use
(json['x'] as num).toDouble()when adoubleis expected. - Forgetting to map nested objects and lists. Call each child's
fromJsonandmapover arrays. - Hand-writing huge models. Switch to
json_serializableonce they grow or change often. - Trusting a number to stay an
int. The same field can arrive as1or1.0; cast throughnumand call.toDouble()rather thanas double. - Assuming keys always exist. A strict
as Stringthrows on a missing or null key — cast through a nullable type (as String?) and fall back with??. - Skipping
build_runnerafter editing an annotated model. The generated.g.dartgoes stale; re-rundart run build_runner build --delete-conflicting-outputs(or usewatch).
Frequently asked questions
How do I parse JSON in Flutter?
Decode the string with jsonDecode from dart:convert to get a Map or List, then pass it into a model's fromJson factory so the rest of your code uses typed objects.
What is the difference between fromJson and toJson?
fromJson is inbound — a factory that builds an object from a decoded map. toJson is outbound — a method that turns the object back into a map for jsonEncode. Most models have both.
How do I parse a JSON list into a list of objects?
Cast the decoded value to List, map each element through your model's fromJson, and toList(): (jsonDecode(body) as List).map((e) => User.fromJson(e)).toList().
Should I hand-write or generate JSON serialization?
Hand-write for small models — it is clear and has no build step. Generate with json_serializable once models grow large, nest deeply, or change often, to avoid error-prone boilerplate.
Further reads
Keep going with the tutorials that pair with this guide:
- Flutter Development Guide 2026 — the full Flutter hub.
- Flutter HTTP Requests — where the JSON comes from.
- Dart Language for Flutter — classes and collections.
- Flutter REST API Integration — models in the data flow.
- Flutter FutureBuilder vs StreamBuilder — render the parsed data.
- Flutter Read and Write Files — persist files with path_provider.
Sources: Flutter documentation — JSON and serialization; dart:convert jsonDecode/jsonEncode (docs.flutter.dev, api.dart.dev). Verified against current stable Flutter.