← Articles

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.

GraphQL in Flutter with ferry — ANN Tech