Skip to main content

GraphQL Client Patterns for Production 2026

·APIScout Team
Share:

GraphQL gives you exactly the data you need. But a production GraphQL client needs more than queries — it needs caching, error handling, optimistic updates, pagination, and offline support. Here are the patterns that work at scale.

Choosing a GraphQL Client

ClientBundle SizeCacheBest For
Apollo Client~33KBNormalizedFull-featured, enterprise
urql~8KBDocument/normalizedLightweight, flexible
TanStack Query + graphql-request~12KB + 2KBQuery-basedSimple, REST-like DX
Relay~30KBNormalized, compilerMeta-scale apps
graphql-request~2KBNoneSimple scripts, SSR

Quick Comparison

// Apollo Client
const { data, loading, error } = useQuery(GET_USER, {
  variables: { id: '123' },
});

// urql
const [result] = useQuery({ query: GET_USER, variables: { id: '123' } });
const { data, fetching, error } = result;

// TanStack Query + graphql-request
const { data, isLoading, error } = useQuery({
  queryKey: ['user', '123'],
  queryFn: () => graphqlClient.request(GET_USER, { id: '123' }),
});

Pattern 1: Type-Safe Queries with Codegen

Generate TypeScript types from your GraphQL schema:

# Install
npm install -D @graphql-codegen/cli @graphql-codegen/typescript \
  @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node

# codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'https://api.example.com/graphql',
  documents: 'src/**/*.graphql',
  generates: {
    './src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typed-document-node',
      ],
    },
  },
};

export default config;
# src/queries/user.graphql
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
    avatar
    posts {
      id
      title
    }
  }
}

mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
  updateUser(id: $id, input: $input) {
    id
    name
    email
  }
}
// Usage — fully type-safe, no `any`
import { GetUserDocument, UpdateUserDocument } from '@/generated/graphql';

const { data } = useQuery(GetUserDocument, { variables: { id: '123' } });
// data.user.name is typed as string
// data.user.posts[0].title is typed as string
// data.user.nonExistent → TypeScript error

Pattern 2: Error Handling

// Centralized error handling
function useGraphQL<TData, TVariables>(
  document: TypedDocumentNode<TData, TVariables>,
  variables?: TVariables
) {
  const { data, loading, error } = useQuery(document, {
    variables,
    errorPolicy: 'all', // Return partial data with errors
  });

  // Categorize errors
  if (error) {
    const graphQLErrors = error.graphQLErrors || [];
    const networkError = error.networkError;

    // Network error (API unreachable)
    if (networkError) {
      return {
        data: null,
        error: { type: 'network', message: 'Unable to connect. Check your connection.' },
        loading: false,
      };
    }

    // Auth error
    const authError = graphQLErrors.find(e =>
      e.extensions?.code === 'UNAUTHENTICATED'
    );
    if (authError) {
      // Redirect to login
      router.push('/login');
      return { data: null, error: { type: 'auth', message: 'Session expired' }, loading: false };
    }

    // Validation error
    const validationError = graphQLErrors.find(e =>
      e.extensions?.code === 'BAD_USER_INPUT'
    );
    if (validationError) {
      return {
        data: null,
        error: {
          type: 'validation',
          message: validationError.message,
          fields: validationError.extensions?.fields,
        },
        loading: false,
      };
    }

    // Generic error
    return {
      data: null,
      error: { type: 'unknown', message: 'Something went wrong' },
      loading: false,
    };
  }

  return { data, error: null, loading };
}

Pattern 3: Optimistic Updates

Update the UI immediately, then sync with the server:

const [updateTodo] = useMutation(UPDATE_TODO, {
  // Optimistic response — show update immediately
  optimisticResponse: {
    updateTodo: {
      __typename: 'Todo',
      id: todoId,
      text: newText,
      completed: true,
    },
  },

  // Update cache with the optimistic (then real) response
  update(cache, { data }) {
    cache.modify({
      id: cache.identify({ __typename: 'Todo', id: todoId }),
      fields: {
        text: () => data.updateTodo.text,
        completed: () => data.updateTodo.completed,
      },
    });
  },

  // If the mutation fails, Apollo automatically reverts the optimistic update
  onError(error) {
    toast.error('Failed to update todo. Please try again.');
  },
});

Pattern 4: Pagination

Cursor-Based (Relay-style)

const GET_POSTS = gql`
  query GetPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        node {
          id
          title
          createdAt
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostList() {
  const { data, loading, fetchMore } = useQuery(GET_POSTS, {
    variables: { first: 20 },
  });

  const loadMore = () => {
    fetchMore({
      variables: {
        first: 20,
        after: data.posts.pageInfo.endCursor,
      },
      updateQuery: (prev, { fetchMoreResult }) => ({
        posts: {
          ...fetchMoreResult.posts,
          edges: [...prev.posts.edges, ...fetchMoreResult.posts.edges],
        },
      }),
    });
  };

  return (
    <div>
      {data?.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
      {data?.posts.pageInfo.hasNextPage && (
        <button onClick={loadMore} disabled={loading}>
          Load More
        </button>
      )}
    </div>
  );
}

Infinite Scroll

function useInfiniteGraphQL(query: DocumentNode, variables: any) {
  const { data, loading, fetchMore } = useQuery(query, { variables });
  const observerRef = useRef<IntersectionObserver>();
  const loadMoreRef = useCallback((node: HTMLElement | null) => {
    if (loading) return;
    if (observerRef.current) observerRef.current.disconnect();

    observerRef.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && data?.posts.pageInfo.hasNextPage) {
        fetchMore({
          variables: { after: data.posts.pageInfo.endCursor },
        });
      }
    });

    if (node) observerRef.current.observe(node);
  }, [loading, data, fetchMore]);

  return { data, loading, loadMoreRef };
}

// Usage
function PostFeed() {
  const { data, loading, loadMoreRef } = useInfiniteGraphQL(GET_POSTS, { first: 20 });

  return (
    <div>
      {data?.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
      <div ref={loadMoreRef} /> {/* Trigger when visible */}
      {loading && <Spinner />}
    </div>
  );
}

Pattern 5: Subscriptions (Real-Time)

// WebSocket-based real-time updates
const MESSAGES_SUBSCRIPTION = gql`
  subscription OnNewMessage($channelId: ID!) {
    messageCreated(channelId: $channelId) {
      id
      text
      sender {
        id
        name
      }
      createdAt
    }
  }
`;

function ChatRoom({ channelId }: { channelId: string }) {
  const { data: messages } = useQuery(GET_MESSAGES, {
    variables: { channelId },
  });

  // Subscribe to new messages
  useSubscription(MESSAGES_SUBSCRIPTION, {
    variables: { channelId },
    onData({ data }) {
      // New message arrives — update cache
      const newMessage = data.data.messageCreated;
      // Apollo automatically updates if using cache policies
    },
  });

  return (
    <div>
      {messages?.channel.messages.map(msg => (
        <Message key={msg.id} message={msg} />
      ))}
    </div>
  );
}

Pattern 6: Fragment Colocation

Keep data requirements next to the component that uses them:

// UserCard.tsx — declares its own data needs
export const USER_CARD_FRAGMENT = gql`
  fragment UserCardFields on User {
    id
    name
    avatar
    role
  }
`;

function UserCard({ user }: { user: UserCardFieldsFragment }) {
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <span>{user.role}</span>
    </div>
  );
}

// Parent page composes fragments
const GET_TEAM = gql`
  ${USER_CARD_FRAGMENT}
  query GetTeam($teamId: ID!) {
    team(id: $teamId) {
      id
      name
      members {
        ...UserCardFields
      }
    }
  }
`;

Common Mistakes

MistakeImpactFix
No codegenManual types, runtime errorsSet up graphql-codegen
Over-fetching with *Defeats GraphQL's purposeQuery only needed fields
No error categorizationGeneric "something went wrong"Handle auth, validation, network separately
Missing optimistic updatesUI feels slowAdd optimistic responses for mutations
N+1 queries on clientMany round-tripsUse fragments, batch queries
No paginationLoading all data at onceUse cursor-based pagination

Apollo vs urql: Choosing in 2026

Both are excellent production-grade clients — the decision comes down to bundle size tolerance and how much normalized caching complexity you're willing to manage.

Apollo Client's normalized cache is its defining feature. When you fetch a User object anywhere in your app, Apollo stores it by its cache key (typically User:123). When that user's data updates — via a mutation, a refetch, or a direct cache write — every query that included that user automatically reflects the change without you writing any synchronization code. For complex UIs where the same entity (a user, a post, a product) appears in multiple places on screen simultaneously, this is genuinely elegant. The cost is real: Apollo's InMemoryCache configuration is not simple. Field policies, keyFields for custom cache keys, merge functions for paginated lists, and read functions for derived fields all require careful thought. Misconfigured cache behavior produces subtle bugs that are hard to reproduce.

urql's normalized cache (the Graphcache plugin, installed separately) offers comparable power at roughly a quarter of the bundle size — ~8KB versus Apollo's ~33KB. The tradeoff is that Graphcache requires more explicit configuration upfront; Apollo's defaults handle more cases automatically, including simple list appends and object updates. For teams already invested in the Apollo ecosystem — using Apollo Server, Apollo Federation, or Apollo on React Native — staying in the ecosystem has real value: shared tooling, consistent mental models, and one fewer vendor to evaluate.

For greenfield projects where bundle size matters (e-commerce storefronts, content-heavy sites, performance-sensitive SPAs), urql's lighter footprint is worth the slightly different DX. The developer experience is intentionally similar to Apollo, so switching costs are low.

TanStack Query paired with graphql-request is the right call in a specific scenario: you're already using TanStack Query for REST endpoints and want a single, consistent data-fetching layer across your app. You get TanStack Query's excellent devtools, background refetching, and query invalidation, with graphql-request handling the HTTP transport. The limitation is that you don't get normalized caching — each query is cached independently by its query key, not by entity identity. For most CRUD-heavy apps this is fine.

GraphQL in Next.js App Router

App Router changes the rules for GraphQL clients in meaningful ways. The old mental model — initialize Apollo once, wrap the app in ApolloProvider, use hooks everywhere — doesn't translate directly.

Client Components (marked 'use client') work exactly as before. Apollo's useQuery and useMutation hooks, urql's useQuery, and TanStack Query's useQuery all function normally inside Client Components because they run in the browser where React context exists. No changes needed here.

Server Components are a different story. Hooks don't work in Server Components — they run on the server synchronously, without a React lifecycle. There's no Provider context to inject the Apollo client from. The straightforward approach: use graphql-request directly in async Server Components. It's a simple HTTP client with no client-side state, which is exactly what you need for server-side fetches. Fetch your data, pass it as props to Client Components, and let the Client Components handle any subsequent interactive queries with Apollo or urql.

If you need the Apollo cache pre-populated on the client for a smoother initial render, the pattern is: fetch data in a Server Component using the Apollo client in a server-only mode (no reactive cache), serialize it, and hydrate the client-side Apollo cache via ApolloProvider's initialState prop. This is more complex than it sounds and the Apollo docs have specific guidance for App Router. For most teams, the simpler split — graphql-request for server, Apollo/urql for client — is easier to reason about and easier to maintain.


Cache Management in Production

Apollo's normalized cache is a feature until it's a bug. The most common production issue: stale cache entries that don't update when they should, or cache writes that corrupt adjacent query results due to incorrect merge logic.

When to use refetchQueries: mutations that affect multiple queries in ways the cache can't automatically reconcile should explicitly refetch those queries. The standard pattern is including refetchQueries in your useMutation call with the queries that need updating. For example, a mutation that deletes an item from a list should refetch the list query, or use cache.evict to remove the deleted item from the cache by its ID. refetchQueries re-runs the network request; cache.evict + cache.gc() removes the entry and triggers a re-render of affected queries without a network call.

Polling versus subscriptions: useQuery has a built-in pollInterval option for queries that need to stay fresh without real-time subscriptions. For dashboards and status displays where data changes every few minutes, polling (e.g., pollInterval: 30000 for a 30-second interval) is simpler to implement and doesn't require WebSocket infrastructure. Use subscriptions when you need sub-second updates — chat, collaborative editing, live pricing. The operational cost of subscriptions is higher: you need WebSocket-capable infrastructure, connection management, and reconnection logic.

Optimistic UI pitfalls: optimistic updates that don't match the server response produce a visible flash when the real response overwrites the optimistic value. Make your optimistic response match the server's exact shape, including __typename fields and all expected fields — missing fields in the optimistic response cause the component to briefly render with undefined values. Test optimistic updates explicitly with delayed server responses to catch timing-dependent UI glitches.

Cache persistence for offline support: Apollo Client's apollo3-cache-persist library serializes the normalized cache to localStorage or AsyncStorage, allowing the app to show cached data on load before the network response arrives. This is most valuable for mobile apps and progressive web apps where offline or low-connectivity scenarios are common. Cache persistence adds startup complexity (you need to await cache hydration before rendering) and risks showing stale data if the app is opened after a long offline period — include a cache TTL check to clear stale persisted caches on launch.

Testing GraphQL Queries

Apollo Client includes a MockedProvider component for unit testing. Wrap your component in MockedProvider with an array of mocked responses, render the component, and assert the UI matches expectations after the mock resolves:

const mocks = [{
  request: { query: GET_USER, variables: { id: '1' } },
  result: { data: { user: { id: '1', name: 'Alice', email: 'alice@example.com', avatar: null, posts: [] } } },
}];

render(
  <MockedProvider mocks={mocks} addTypename={false}>
    <UserCard userId="1" />
  </MockedProvider>
);

await waitFor(() => {
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

For integration tests with a real GraphQL server, use msw (Mock Service Worker) to intercept the fetch calls made by graphql-request or Apollo's HTTP link. This gives you realistic behavior — network timing, error responses, partial data — without requiring a live server in CI. MSW also works in browser tests (Playwright, Cypress) and Node.js tests without changing your test assertions.

Methodology

GraphQL client versions: Apollo Client 3.11 (stable as of March 2026, includes React 19 Suspense support), urql 4.x with Graphcache plugin, TanStack Query 5.x paired with graphql-request 6.x, Relay 17. graphql-codegen 5.x, @graphql-codegen/cli. Bundle size figures are gzipped production builds without tree-shaking; actual bundle impact depends on which Apollo features your app uses. Next.js App Router GraphQL patterns are as of Next.js 15 stable. Apollo Server 4.x for server-side context. urql's Graphcache plugin must be installed separately (@urql/exchange-graphcache) — the normalized cache is not included in the base urql package. Apollo DevTools browser extension works with all Apollo Client 3.x versions and provides real-time cache inspection, query tracking, and mutation monitoring — install it for any production app using Apollo before debugging cache issues in the browser.


Compare GraphQL APIs and clients on APIScout — find the best GraphQL endpoints, compare schema designs, and evaluate DX.

Related: API Error Handling Patterns for Production Applications, API Pagination: Cursor vs Offset in 2026, Best GraphQL Federation Platforms 2026

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.