GraphQL Client Patterns for Production 2026
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
| Client | Bundle Size | Cache | Best For |
|---|---|---|---|
| Apollo Client | ~33KB | Normalized | Full-featured, enterprise |
| urql | ~8KB | Document/normalized | Lightweight, flexible |
| TanStack Query + graphql-request | ~12KB + 2KB | Query-based | Simple, REST-like DX |
| Relay | ~30KB | Normalized, compiler | Meta-scale apps |
| graphql-request | ~2KB | None | Simple 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
| Mistake | Impact | Fix |
|---|---|---|
| No codegen | Manual types, runtime errors | Set up graphql-codegen |
Over-fetching with * | Defeats GraphQL's purpose | Query only needed fields |
| No error categorization | Generic "something went wrong" | Handle auth, validation, network separately |
| Missing optimistic updates | UI feels slow | Add optimistic responses for mutations |
| N+1 queries on client | Many round-trips | Use fragments, batch queries |
| No pagination | Loading all data at once | Use 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