GraphQL in Flutter with ferry
By Ann Tech · 18 August 2025
GraphQL gives your Flutter app a typed, queryable API where you ask for exactly the data you need — no more over-fetching. The ferry package is the most feature-complete GraphQL client for Flutter, with code generation, caching, and offline support.
When to use GraphQL
GraphQL is worth the setup cost when:
- Your backend already has a GraphQL API
- You're building a complex screen that pulls from multiple data sources
- You want to avoid over-fetching (REST APIs return fixed shapes)
- You need real-time subscriptions alongside queries
Setup
dependencies:
ferry: ^0.15.0
ferry_flutter: ^0.10.0
gql_http_link: ^0.4.0
gql_websocket_link: ^0.4.0 # For subscriptions
dev_dependencies:
ferry_generator: ^0.10.0
build_runner: ^2.4.9
Defining queries
Write .graphql files alongside your Dart code:
# lib/src/orders/orders.graphql
query GetOrders($status: OrderStatus, $first: Int!) {
orders(status: $status, first: $first) {
edges {
node {
id
status
total
createdAt
customer {
name
email
}
items {
productName
quantity
unitPrice
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
mutation CancelOrder($orderId: ID!) {
cancelOrder(id: $orderId) {
id
status
}
}
subscription OrderUpdates($orderId: ID!) {
orderUpdated(id: $orderId) {
id
status
updatedAt
}
}
Code generation
# build.yaml
targets:
$default:
builders:
ferry_generator|graphql_builder:
options:
schema: lib/schema.graphql
flutter pub run build_runner build --delete-conflicting-outputs
This generates typed Dart classes for every query, mutation, and subscription.
Client setup
import 'package:ferry/ferry.dart';
import 'package:gql_http_link/gql_http_link.dart';
Future<Client> buildClient(String token) async {
final link = HttpLink(
'https://api.example.com/graphql',
defaultHeaders: {'Authorization': 'Bearer $token'},
);
final cache = Cache();
return Client(link: link, cache: cache);
}
Running a query in a widget
class OrdersScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(graphqlClientProvider);
final request = GGetOrdersReq(
(b) => b
..vars.status = GOrderStatus.pending
..vars.first = 20,
);
return Operation(
client: client,
operationRequest: request,
builder: (context, response, error) {
if (response?.loading ?? true) {
return const CircularProgressIndicator();
}
if (error != null) {
return ErrorView(message: error.toString());
}
final orders = response?.data?.orders.edges
.map((e) => e.node)
.toList() ?? [];
return OrdersList(orders: orders);
},
);
}
}
Mutations
Future<void> cancelOrder(String orderId) async {
final client = ref.read(graphqlClientProvider);
final request = GCancelOrderReq(
(b) => b..vars.orderId = orderId,
);
final response = await client.request(request).first;
if (response.hasErrors) {
throw Exception(response.graphqlErrors?.first.message);
}
// Cache is automatically updated if the mutation returns the updated object
}
Real-time subscriptions
final wsLink = WebSocketLink(
'wss://api.example.com/graphql',
config: SocketClientConfig(
initialPayload: {'Authorization': 'Bearer $token'},
),
);
// In widget:
final subRequest = GOrderUpdatesReq(
(b) => b..vars.orderId = orderId,
);
StreamBuilder<OperationResponse<GOrderUpdatesData, GOrderUpdatesVars>>(
stream: client.request(subRequest),
builder: (context, snapshot) {
final status = snapshot.data?.data?.orderUpdated.status;
return OrderStatusBadge(status: status?.name ?? 'Unknown');
},
)
Caching and optimistic updates
// Optimistic update: show success immediately, revert on error
final request = GCancelOrderReq(
(b) => b
..vars.orderId = orderId
..optimisticResponse = GCancelOrderData(
(b) => b
..cancelOrder.id = orderId
..cancelOrder.status = GOrderStatus.cancelled,
),
);
Common pitfalls
Not sharing the client instance. Create the GraphQL client once and inject it via Riverpod or get_it. Creating a new client per widget creates a new cache per widget — all caching is lost.
N+1 queries. GraphQL clients can still cause N+1 problems if the backend resolver isn't optimized. If you query a list of orders and each order's customer is resolved separately, you'll make N+1 database queries on the server. The fix is on the backend (DataLoader), but you can work around it by restructuring your query to include all needed nested data in one query.
Not handling partial errors. GraphQL returns 200 for partial success — some fields may be null with errors in the errors array. Always check response.hasErrors even when response.data is non-null.
Sign in to like, dislike, or report.