Development

Flutter GraphQL: Building Apps with graphql_flutter & Ferry

Muhammad Shakil Muhammad Shakil
Apr 13, 2026
18 min read
Flutter GraphQL integration with graphql_flutter and Ferry
Back to Blog

REST APIs send you everything whether you need it or not. Request a user profile and you get 40 fields when you needed 3. Need data from two endpoints? That's two round trips. GraphQL fixes both problems: you specify exactly what you want, and the server returns exactly that — in a single request.

Flutter has two main GraphQL clients: graphql_flutter (widget-based, quick to adopt) and Ferry (code-generated, type-safe). I've used both in production. This guide covers both — starting with graphql_flutter for most sections, then showing the Ferry approach for teams that want compile-time safety.

What You'll Learn

1. Why GraphQL in Flutter

GraphQL isn't better than REST in every situation. Use it when it solves a real problem:

Factor REST GraphQL
Data fetching Fixed response shape Client picks exact fields
Multiple resources Multiple requests Single request
Over-fetching Common Eliminated by design
Under-fetching Need extra endpoints Nest related data
Versioning URL-based (v1, v2) Schema evolution, no versions
Tooling complexity Low Higher (schema, codegen)
Caching HTTP caching built-in Client-side normalized cache

The tipping point is data complexity. If your screens pull from 3+ endpoints or show deeply nested relationships (user → orders → items → reviews), GraphQL turns 5 REST calls into 1 GraphQL query.

2. Setting Up graphql_flutter

Add the graphql_flutter package to your project. It wraps the core graphql client with Flutter-specific widgets like Query, Mutation, and Subscription.

# pubspec.yaml
dependencies:
  graphql_flutter: ^5.2.0
import 'package:graphql_flutter/graphql_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Hive for persistent caching (uses hive package internally)
  // See: https://pub.dev/packages/hive
  await initHiveForFlutter();

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final httpLink = HttpLink('https://api.example.com/graphql');

    final authLink = AuthLink(
      getToken: () async {
        final token = await SecureStorage.getToken();
        return token != null ? 'Bearer $token' : null;
      },
    );

    final link = authLink.concat(httpLink);

    final client = ValueNotifier(
      GraphQLClient(
        link: link,
        cache: GraphQLCache(store: HiveStore()),
      ),
    );

    return GraphQLProvider(
      client: client,
      child: const MaterialApp(home: HomeScreen()),
    );
  }
}

3. Running Queries

graphql_flutter provides a Query widget that rebuilds when data arrives:

const String getProductsQuery = r'''
  query GetProducts($category: String, $limit: Int!) {
    products(category: $category, limit: $limit) {
      id
      name
      price
      imageUrl
      rating
      reviewCount
    }
  }
''';

class ProductListScreen extends StatelessWidget {
  const ProductListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: Query(
        options: QueryOptions(
          document: gql(getProductsQuery),
          variables: const {'category': 'electronics', 'limit': 20},
          fetchPolicy: FetchPolicy.cacheAndNetwork,
        ),
        builder: (result, {fetchMore, refetch}) {
          if (result.isLoading && result.data == null) {
            return const Center(child: CircularProgressIndicator());
          }

          if (result.hasException) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text('Error: ${result.exception.toString()}'),
                  ElevatedButton(
                    onPressed: refetch,
                    child: const Text('Retry'),
                  ),
                ],
              ),
            );
          }

          final products = result.data!['products'] as List;

          return RefreshIndicator(
            onRefresh: () async => refetch!(),
            child: ListView.builder(
              itemCount: products.length,
              itemBuilder: (context, index) {
                final product = products[index];
                return ListTile(
                  title: Text(product['name']),
                  subtitle: Text('\$${product['price']}'),
                  trailing: Text('${product['rating']} ★'),
                );
              },
            ),
          );
        },
      ),
    );
  }
}

Imperative Queries (Without Widgets)

For queries outside the widget tree (in repositories, services, BLoCs):

class ProductRepository {
  final GraphQLClient _client;
  ProductRepository(this._client);

  Future<List<Product>> getProducts({String? category, int limit = 20}) async {
    final result = await _client.query(
      QueryOptions(
        document: gql(getProductsQuery),
        variables: {'category': category, 'limit': limit},
        fetchPolicy: FetchPolicy.networkOnly,
      ),
    );

    if (result.hasException) {
      throw GraphQLException(result.exception!);
    }

    return (result.data!['products'] as List)
        .map((json) => Product.fromJson(json))
        .toList();
  }
}

4. Running Mutations

const String createOrderMutation = r'''
  mutation CreateOrder($input: CreateOrderInput!) {
    createOrder(input: $input) {
      id
      status
      total
      items {
        productId
        quantity
        price
      }
    }
  }
''';

class CheckoutScreen extends StatelessWidget {
  const CheckoutScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Mutation(
      options: MutationOptions(
        document: gql(createOrderMutation),
        onCompleted: (data) {
          if (data != null) {
            final orderId = data['createOrder']['id'];
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('Order $orderId created!')),
            );
            Navigator.of(context).pushReplacementNamed('/orders');
          }
        },
        onError: (error) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Failed: ${error.toString()}')),
          );
        },
        // Update the cache after mutation
        update: (cache, result) {
          // Evict the products list so it refetches
          cache.writeQuery(
            Request(operation: Operation(document: gql(getProductsQuery))),
            data: null,
          );
        },
      ),
      builder: (runMutation, result) {
        return ElevatedButton(
          onPressed: result!.isLoading
              ? null
              : () => runMutation({
                    'input': {
                      'items': [
                        {'productId': 'p1', 'quantity': 2},
                        {'productId': 'p3', 'quantity': 1},
                      ],
                      'shippingAddress': '123 Main St',
                    },
                  }),
          child: result.isLoading
              ? const CircularProgressIndicator()
              : const Text('Place Order'),
        );
      },
    );
  }
}

5. Variables and Fragments

Fragments let you reuse field selections across queries:

const String productFields = r'''
  fragment ProductFields on Product {
    id
    name
    price
    imageUrl
    rating
    reviewCount
    category {
      id
      name
    }
  }
''';

const String getProductQuery = r'''
  query GetProduct($id: ID!) {
    product(id: $id) {
      ...ProductFields
      description
      specifications {
        key
        value
      }
      reviews(limit: 5) {
        author
        rating
        comment
        createdAt
      }
    }
  }
''' + productFields;

const String getRelatedProducts = r'''
  query GetRelated($categoryId: ID!, $excludeId: ID!, $limit: Int!) {
    relatedProducts(categoryId: $categoryId, excludeId: $excludeId, limit: $limit) {
      ...ProductFields
    }
  }
''' + productFields;

6. Caching Strategies

graphql_flutter uses a normalized cache. Every object with an id (or __typename + id) gets stored once. Update it in one place and it updates everywhere automatically.

// Fetch policies control how the cache is used:

// 1. cacheFirst — use cache, only hit network if cache misses
QueryOptions(
  document: gql(query),
  fetchPolicy: FetchPolicy.cacheFirst, // Default
);

// 2. networkOnly — always hit the network, update cache
QueryOptions(
  document: gql(query),
  fetchPolicy: FetchPolicy.networkOnly,
);

// 3. cacheAndNetwork — show cache immediately, then update from network
//    Best UX: instant display + fresh data
QueryOptions(
  document: gql(query),
  fetchPolicy: FetchPolicy.cacheAndNetwork,
);

// 4. noCache — don't read or write cache
QueryOptions(
  document: gql(query),
  fetchPolicy: FetchPolicy.noCache,
);

Manual Cache Updates

// Read from cache
final cachedData = client.cache.readQuery(
  Request(
    operation: Operation(document: gql(getProductsQuery)),
    variables: const {'category': 'electronics', 'limit': 20},
  ),
);

// Write to cache (optimistic update)
client.cache.writeQuery(
  Request(
    operation: Operation(document: gql(getProductsQuery)),
    variables: const {'category': 'electronics', 'limit': 20},
  ),
  data: updatedData,
);

// Evict a specific entity
client.cache.evict('Product:p123');

// Clear the entire cache
client.cache.store.reset();

7. Real-Time Subscriptions

Subscriptions maintain a WebSocket connection for real-time data:

// Add WebSocket link to your client
final wsLink = WebSocketLink(
  'wss://api.example.com/graphql',
  config: SocketClientConfig(
    autoReconnect: true,
    inactivityTimeout: const Duration(seconds: 30),
    initialPayload: () async {
      final token = await SecureStorage.getToken();
      return {'Authorization': 'Bearer $token'};
    },
  ),
);

// Split link: subscriptions go through WebSocket, everything else through HTTP
final link = Link.split(
  (request) => request.isSubscription,
  wsLink,
  authLink.concat(httpLink),
);
const String onNewMessage = r'''
  subscription OnNewMessage($chatId: ID!) {
    messageAdded(chatId: $chatId) {
      id
      text
      sender {
        id
        name
        avatarUrl
      }
      createdAt
    }
  }
''';

class ChatScreen extends StatelessWidget {
  final String chatId;
  const ChatScreen({super.key, required this.chatId});

  @override
  Widget build(BuildContext context) {
    return Subscription(
      options: SubscriptionOptions(
        document: gql(onNewMessage),
        variables: {'chatId': chatId},
      ),
      builder: (result) {
        if (result.isLoading) {
          return const Text('Connecting...');
        }

        if (result.hasException) {
          return Text('Error: ${result.exception}');
        }

        final message = result.data?['messageAdded'];
        if (message == null) return const SizedBox.shrink();

        return MessageBubble(
          text: message['text'],
          sender: message['sender']['name'],
          time: DateTime.parse(message['createdAt']),
        );
      },
    );
  }
}

8. Pagination: Cursor and Offset

Cursor-Based Pagination

const String paginatedProducts = r'''
  query PaginatedProducts($first: Int!, $after: String) {
    products(first: $first, after: $after) {
      edges {
        node {
          id
          name
          price
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
''';

class PaginatedProductList extends StatelessWidget {
  const PaginatedProductList({super.key});

  @override
  Widget build(BuildContext context) {
    return Query(
      options: QueryOptions(
        document: gql(paginatedProducts),
        variables: const {'first': 20},
      ),
      builder: (result, {fetchMore, refetch}) {
        if (result.isLoading && result.data == null) {
          return const Center(child: CircularProgressIndicator());
        }

        final connection = result.data!['products'];
        final edges = connection['edges'] as List;
        final pageInfo = connection['pageInfo'];

        return NotificationListener<ScrollNotification>(
          onNotification: (scrollInfo) {
            if (scrollInfo.metrics.pixels >
                    scrollInfo.metrics.maxScrollExtent - 200 &&
                pageInfo['hasNextPage'] == true &&
                !result.isLoading) {
              fetchMore!(
                FetchMoreOptions(
                  variables: {'after': pageInfo['endCursor']},
                  updateQuery: (previous, fetchMoreResult) {
                    final newEdges =
                        fetchMoreResult!['products']['edges'] as List;
                    final merged = {
                      'products': {
                        'edges': [
                          ...previous!['products']['edges'] as List,
                          ...newEdges,
                        ],
                        'pageInfo': fetchMoreResult['products']['pageInfo'],
                      },
                    };
                    return merged;
                  },
                ),
              );
            }
            return false;
          },
          child: ListView.builder(
            itemCount: edges.length,
            itemBuilder: (context, index) {
              final product = edges[index]['node'];
              return ListTile(
                title: Text(product['name']),
                trailing: Text('\$${product['price']}'),
              );
            },
          ),
        );
      },
    );
  }
}

9. Ferry: Type-Safe GraphQL

Ferry generates Dart classes from your .graphql files using build_runner. No more result.data!['products'] — you get real types with autocomplete.

# pubspec.yaml
dependencies:
  ferry: ^0.15.0          # https://pub.dev/packages/ferry
  gql_http_link: ^1.0.0   # https://pub.dev/packages/gql_http_link

dev_dependencies:
  ferry_generator: ^0.10.0 # https://pub.dev/packages/ferry_generator
  build_runner: ^2.4.0     # https://pub.dev/packages/build_runner
# build.yaml
targets:
  $default:
    builders:
      ferry_generator:
        options:
          schema: lib/graphql/schema.graphql
          queries_glob: lib/graphql/*.graphql
          output_dir: lib/graphql/__generated__
# lib/graphql/get_products.graphql
query GetProducts($category: String, $limit: Int!) {
  products(category: $category, limit: $limit) {
    id
    name
    price
    imageUrl
    rating
  }
}
# Generate types
dart run build_runner build --delete-conflicting-outputs
import 'package:ferry/ferry.dart';
import 'graphql/__generated__/get_products.req.gql.dart';
import 'graphql/__generated__/get_products.data.gql.dart';

class ProductListScreen extends StatelessWidget {
  final Client ferryClient;
  const ProductListScreen({super.key, required this.ferryClient});

  @override
  Widget build(BuildContext context) {
    final request = GGetProductsReq((b) => b
      ..vars.category = 'electronics'
      ..vars.limit = 20);

    return Operation(
      client: ferryClient,
      operationRequest: request,
      builder: (context, response, error) {
        if (response == null || response.loading) {
          return const CircularProgressIndicator();
        }

        // Fully typed! IDE autocomplete works here.
        final products = response.data?.products ?? [];

        return ListView.builder(
          itemCount: products.length,
          itemBuilder: (context, index) {
            final product = products[index];
            // product.name, product.price — all typed
            return ListTile(
              title: Text(product.name),
              subtitle: Text('\$${product.price}'),
            );
          },
        );
      },
    );
  }
}

10. Error Handling Patterns

GraphQL errors come in two forms: network errors (no connection, timeout) and GraphQL errors (validation failures, auth errors). Handle both:

class GraphQLException implements Exception {
  final OperationException exception;
  GraphQLException(this.exception);

  bool get isNetworkError => exception.linkException != null;
  bool get isAuthError => graphqlErrors.any(
    (e) => e.extensions?['code'] == 'UNAUTHENTICATED',
  );

  List<GraphQLError> get graphqlErrors => exception.graphqlErrors;

  String get userMessage {
    if (isNetworkError) return 'No internet connection. Check your network.';
    if (isAuthError) return 'Session expired. Please sign in again.';
    if (graphqlErrors.isNotEmpty) return graphqlErrors.first.message;
    return 'Something went wrong. Please try again.';
  }

  @override
  String toString() => userMessage;
}

// Usage in a repository
Future<Product> getProduct(String id) async {
  final result = await _client.query(
    QueryOptions(
      document: gql(getProductQuery),
      variables: {'id': id},
    ),
  );

  if (result.hasException) {
    final error = GraphQLException(result.exception!);

    if (error.isAuthError) {
      // Trigger re-authentication flow
      await _authService.refreshToken();
      return getProduct(id); // Retry once
    }

    throw error;
  }

  return Product.fromJson(result.data!['product']);
}

11. Production Tips

Related Guides

Frequently Asked Questions

Should I use REST or GraphQL for my Flutter app?

Use REST when your API is simple (few endpoints, stable data shapes) and when you're working with third-party APIs that only offer REST. Use GraphQL when your app has complex data requirements — screens pulling from multiple sources, nested relationships, or real over-fetching problems. The official GraphQL docs have a good intro. Don't adopt it just because it's trendy. Let your data access patterns drive the decision.

What's the difference between graphql_flutter and Ferry?

graphql_flutter is simpler — Query/Mutation/Subscription widgets that slot right into the widget tree, no code generation needed. Ferry uses build_runner to generate type-safe request classes from .graphql files. Ferry gives you compile-time safety and IDE autocomplete but requires more setup. Use graphql_flutter for smaller apps. Use Ferry when type safety matters.

How does GraphQL caching work in Flutter?

graphql_flutter uses a normalized in-memory cache. Objects with an id field get stored once — update a user in one query and it updates everywhere that user appears. Configure fetch policies per query: cacheFirst, networkOnly, cacheAndNetwork. For persistence across app restarts, use HiveStore.

Can I use GraphQL subscriptions for real-time features?

Yes. Subscriptions use WebSocket connections for real-time streaming. In graphql_flutter, use the Subscription widget with a WebSocketLink. Works well for chat, live notifications, dashboards, and collaborative editing. Your backend needs subscription support — Apollo Server, Hasura, and AWS AppSync all have it.

How do I handle file uploads with GraphQL in Flutter?

GraphQL doesn't natively support file uploads. Use the GraphQL Multipart Request spec (used by Apollo) with MultipartFile from the http package. Alternatively, upload files to a storage service (S3, Firebase Storage) via REST, get the URL back, and send that URL through a GraphQL mutation. The second approach is usually simpler.

Is GraphQL slower than REST?

Not inherently. A well-designed GraphQL query can be faster — it fetches exactly what you need in one request instead of multiple REST calls. But complex nested queries can be expensive server-side (N+1 problem). The pagination docs cover efficient data loading patterns. Performance depends on query complexity, server implementation, and caching — not on the protocol itself.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.