← All posts
·13 min read

Claude Code with urql: GraphQL Client Without the Apollo Weight

Claude CodeurqlGraphQLReact
Claude Code with urql: GraphQL client without the Apollo weight

Why urql without CLAUDE.md ships a client that caches the wrong things

urql is the GraphQL client people pick when Apollo feels too heavy. The default client is a few kilobytes, the API is a small set of exchanges that plug into a pipeline, and the document cache works for most apps without configuration. The price for the smaller surface is that the defaults are deliberately minimal. Document cache is by query, not by entity, so a mutation that changes a single user does not update the list query that contains them. Graphcache is the normalised cache that does, but it is a separate package with its own configuration. Pick the wrong exchange order and dedup runs after the cache, so identical queries hit the network. Skip codegen and every useQuery returns data: any.

Claude Code does not know any of this by default. It generates createClient({ url, exchanges: [cacheExchange, fetchExchange] }) and ships. The most common Claude defaults that break urql in production: missing dedupExchange so concurrent queries hit the network twice, document cache assumed to be entity-aware so mutations leave stale list queries on screen, graphcache configured without keys for types that do not have an id field, optimistic updates without rollback so a failed mutation leaves a phantom row, subscriptions wired through the default exchange chain instead of via subscriptionExchange, codegen not configured so every query result is any, and SSR rehydration that mismatches because the server and client caches were not synchronised through ssrExchange.

This guide covers the CLAUDE.md configuration that locks Claude Code into urql's exchange-ordered, codegen-fed model: exchange order with dedupExchange first, cacheExchange second (document or graphcache), subscriptionExchange for subscriptions, fetchExchange last, graphcache configured with explicit keys for non-id types, mutations with updates for cache invalidation, optimistic updates with rollback semantics, codegen via @graphql-codegen/typed-document-node so useQuery returns typed data, and SSR via ssrExchange initialised on the server and rehydrated on the client. For the React patterns urql depends on, Claude Code with React Query covers the data-fetching mental model that overlaps with urql's exchange pipeline.

The urql CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a urql integration it needs to declare: the urql version line, the exchanges in use and their order, the cache strategy (document or graphcache), the codegen pipeline, the subscriptions transport, the SSR strategy, and the hard rules that block the mistakes Claude makes most often.

# urql rules

## Stack
- urql ^4.x (core client)
- @urql/exchange-graphcache ^7.x (normalised cache, NOT document cache)
- @urql/exchange-auth ^3.x (token refresh)
- graphql-ws ^5.x + wonka for subscriptions
- @graphql-codegen/cli + @graphql-codegen/typed-document-node for typed documents
- @urql/next or pages-router-specific SSR wrapper for Next.js

## Project structure
- src/graphql/client.ts        , createClient + exchange chain
- src/graphql/cache.ts          , graphcache configuration (keys, updates, resolvers)
- src/graphql/documents/        , .graphql files (one per operation)
- src/generated/graphql.ts      , codegen output, imported as TypedDocumentNode
- src/graphql/ssr.ts            , ssrExchange initialisation per request
- codegen.ts                    , GraphQL Codegen config

## Exchange order (MANDATORY)
- dedupExchange FIRST (collapse concurrent identical queries)
- cacheExchange SECOND (document cache OR graphcache, NOT both)
- ssrExchange THIRD if Next.js / SSR (pulls hydration data)
- authExchange FOURTH if token refresh (runs before fetch so 401s retry)
- subscriptionExchange FIFTH if subscriptions
- fetchExchange LAST

## Cache choice (MANDATORY decision)
- documentCacheExchange: simple apps, queries fully refetch on mutation
- graphcache + cacheExchange from @urql/exchange-graphcache: entity-aware
- NEVER mix the two

## Graphcache configuration (when used)
- keys: map of type name to fn returning the cache key
- ALWAYS set keys for any type without an 'id' field
- updates: map of mutation field name to fn that updates the cache
- optimistic: map of mutation field name to fn that returns optimistic data
- resolvers: map for relay-style pagination merge

## Codegen (MANDATORY)
- pnpm graphql-codegen runs before pnpm dev and pnpm build
- generates TypedDocumentNode for every operation
- useQuery({ query: TheQuery }) returns data: TheQuery['Result'] typed
- NEVER write raw gql`` strings without a corresponding codegen entry

## Subscriptions (when used)
- transport via graphql-ws createClient
- subscriptionExchange in the exchange chain BEFORE fetchExchange
- NEVER share the WebSocket client across React strict-mode double mounts

## SSR (when used with Next.js)
- ssrExchange({ isClient: false }) on server, extractData() into props
- ssrExchange({ isClient: true, initialState: pageProps.urqlState }) on client
- ALWAYS pass initialState exactly once on hydration

## Hard rules
- NEVER use the document cache when entity invalidation matters
- NEVER omit dedupExchange (concurrent identical queries hit network twice)
- NEVER place fetchExchange anywhere but last
- NEVER write a graphcache update without a matching cache.invalidate or cache.updateQuery
- NEVER ship without codegen (data is typed as 'any' otherwise)
- NEVER pass a JS object literal as the query, ALWAYS a TypedDocumentNode
- NEVER set requestPolicy: 'network-only' as a default (defeats the cache)
- ALWAYS run codegen as part of the build pipeline

Five rules here prevent the majority of incidents Claude generates without them.

The exchange order rule prevents the silent dedup miss that doubles backend load. urql's exchange chain is a pipeline. The order is the order of the array passed to createClient. If cacheExchange runs before dedupExchange, two concurrent identical queries both miss the cache (because neither has filled it yet), both bypass dedup (because it ran first and saw nothing), and both hit the network. Claude orders the exchanges by what looks "most important first" rather than the actual semantic order. The rule "dedup FIRST, cache SECOND, fetch LAST" makes the pipeline explicit.

The cache choice rule prevents the most common urql confusion: assuming the document cache is entity-aware. It is not. The document cache keys by query plus variables, so a mutation that updates a user does not touch a list query that contains the user. The list shows stale data until the user refreshes. Graphcache is the normalised cache that solves this, but it is a different exchange with different configuration. The rule "documentCacheExchange OR graphcache, NEVER both" forces a decision at setup time instead of debug time.

The graphcache keys rule prevents the most subtle bug in normalised GraphQL clients: types without id fields stored under the wrong key. Graphcache uses __typename and id to identify entities by default. A type like Address that uses addressId instead of id gets cached by Address.unknown which collides with every other Address. The rule "ALWAYS set keys for any type without an id field" makes the entity identity explicit.

The codegen rule prevents the loss of type safety that the entire reason for using TypeScript exists to prevent. Without codegen, useQuery({ query: gql\...` })returnsdata: any`. The IDE drops autocomplete, the type checker waves through misspelled field names, and bugs surface at runtime. The rule "MANDATORY codegen, NEVER raw gql strings" makes typed documents the only way to write queries.

The subscription transport rule prevents the duplicate-subscription bug that hits React 18 strict mode. A subscription opened in a component effect opens once on the first mount, opens again on the strict-mode second mount, and the server sees two clients listening for the same channel. The rule "subscriptionExchange in the chain" routes subscriptions through the same client that dedups queries, and the transport is a singleton outside React state.

Install and client setup

Install urql, graphcache, and codegen tooling:

pnpm add urql graphql @urql/exchange-graphcache
pnpm add -D @graphql-codegen/cli @graphql-codegen/typed-document-node @graphql-codegen/typescript @graphql-codegen/typescript-operations

Configure codegen in codegen.ts:

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

const config: CodegenConfig = {
  schema: 'http://localhost:4000/graphql',
  documents: ['src/**/*.graphql', 'src/**/*.tsx', 'src/**/*.ts'],
  generates: {
    'src/generated/graphql.ts': {
      plugins: ['typescript', 'typescript-operations', 'typed-document-node'],
      config: {
        useTypeImports: true,
        skipTypename: false,
        dedupeFragments: true,
      },
    },
  },
};

export default config;

Add the codegen script to package.json:

{
  "scripts": {
    "codegen": "graphql-codegen --config codegen.ts",
    "dev": "pnpm codegen && next dev",
    "build": "pnpm codegen && next build"
  }
}

Build the client with the canonical exchange order:

// src/graphql/client.ts
import { createClient, dedupExchange, fetchExchange, subscriptionExchange } from 'urql';
import { cacheExchange } from '@urql/exchange-graphcache';
import { authExchange } from '@urql/exchange-auth';
import { createClient as createWSClient } from 'graphql-ws';
import { cacheConfig } from './cache';
import { getToken, refreshToken } from '@/auth';

const wsClient = createWSClient({
  url: process.env.NEXT_PUBLIC_GRAPHQL_WS_URL ?? '',
  connectionParams: () => ({ token: getToken() }),
});

export const client = createClient({
  url: process.env.NEXT_PUBLIC_GRAPHQL_URL ?? '',
  exchanges: [
    dedupExchange,
    cacheExchange(cacheConfig),
    authExchange(async (utils) => ({
      addAuthToOperation(operation) {
        const token = getToken();
        if (!token) return operation;
        return utils.appendHeaders(operation, { Authorization: `Bearer ${token}` });
      },
      didAuthError(error) {
        return error.graphQLErrors.some((e) => e.extensions?.code === 'UNAUTHENTICATED');
      },
      async refreshAuth() {
        await refreshToken();
      },
    })),
    subscriptionExchange({
      forwardSubscription(operation) {
        return {
          subscribe(sink) {
            const dispose = wsClient.subscribe(
              { ...operation, query: operation.query ?? '' },
              sink as any,
            );
            return { unsubscribe: dispose };
          },
        };
      },
    }),
    fetchExchange,
  ],
});

Three things in this snippet that the CLAUDE.md rule enforces. The exchanges are in canonical order: dedup, cache, auth, subscription, fetch. The WebSocket client is created at module scope so strict-mode double mount does not open two connections. The auth exchange is configured for both adding the token to operations and refreshing on 401, so expired tokens trigger refresh instead of failing the query.

Graphcache configuration

Graphcache is the normalised cache. It needs three pieces of configuration: keys for any type without an id field, updates for mutations that change cached entities, and resolvers for pagination.

// src/graphql/cache.ts
import type { CacheExchangeOpts } from '@urql/exchange-graphcache';
import { offsetPagination, relayPagination } from '@urql/exchange-graphcache/extras';

export const cacheConfig: CacheExchangeOpts = {
  keys: {
    Address: (data) => data.addressId as string,
    Geolocation: () => null,
  },
  resolvers: {
    Query: {
      posts: relayPagination(),
      messages: offsetPagination(),
    },
  },
  updates: {
    Mutation: {
      createPost(result, _args, cache) {
        cache.updateQuery({ query: PostsQuery }, (data) => {
          if (!data) return data;
          return {
            ...data,
            posts: {
              ...data.posts,
              edges: [
                { __typename: 'PostEdge', node: result.createPost, cursor: '' },
                ...data.posts.edges,
              ],
            },
          };
        });
      },
      deletePost(result, args, cache) {
        cache.invalidate({ __typename: 'Post', id: args.id as string });
      },
    },
  },
  optimistic: {
    deletePost: (args) => ({
      __typename: 'DeletePostResult',
      id: args.id,
      success: true,
    }),
  },
};

Four patterns in this configuration that the CLAUDE.md rule enforces. The keys map gives Address a custom key via addressId, and tells graphcache that Geolocation is embedded (not a separate entity) by returning null. The resolvers.Query.posts uses the built-in relayPagination helper so connection edges merge correctly. The updates.Mutation.createPost inserts the new post into the cached list query so the UI updates without refetch. The updates.Mutation.deletePost invalidates the deleted entity so any query containing it refetches.

Add a graphcache rule to CLAUDE.md:

## Graphcache configuration (CONCRETE)

cacheConfig = {
  keys: {
    <TypeWithoutId>: (data) => data.<customKey>
    <EmbeddedType>: () => null
  },
  resolvers: {
    Query: {
      <pagedField>: relayPagination() | offsetPagination()
    }
  },
  updates: {
    Mutation: {
      <createField>: (result, args, cache) => cache.updateQuery({...})
      <deleteField>: (result, args, cache) => cache.invalidate({ __typename, id })
    }
  },
  optimistic: {
    <field>: (args, cache) => optimisticResult
  }
}

ALWAYS: keys for every type without an id field
ALWAYS: updates for every mutation that changes a cached list
NEVER: optimistic without a matching updates entry (rollback breaks)

Typed queries with codegen

With codegen running, write queries in .graphql files or as gql tagged templates inside components. The codegen pass produces a TypedDocumentNode for each operation, and useQuery infers the result type automatically.

A query file:

# src/graphql/documents/posts.graphql
query Posts($first: Int!, $after: String) {
  posts(first: $first, after: $after) {
    edges {
      node {
        id
        title
        excerpt
        createdAt
        author {
          id
          name
          avatarUrl
        }
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

After codegen, the generated file exports PostsDocument. Use it in a component:

// src/components/PostList.tsx
'use client';

import { useQuery } from 'urql';
import { PostsDocument } from '@/generated/graphql';

export function PostList() {
  const [{ data, fetching, error }] = useQuery({
    query: PostsDocument,
    variables: { first: 20 },
  });

  if (fetching) return <Spinner />;
  if (error) return <ErrorPanel error={error} />;
  if (!data) return null;

  return (
    <ul>
      {data.posts.edges.map((edge) => (
        <li key={edge.node.id}>
          <h3>{edge.node.title}</h3>
          <p>{edge.node.excerpt}</p>
          <span>by {edge.node.author.name}</span>
        </li>
      ))}
    </ul>
  );
}

Three patterns in this component that the CLAUDE.md rule enforces. The query is a TypedDocumentNode imported from the generated file, not a raw gql string. The data type is inferred, so edge.node.title is string and edge.node.author.avatarUrl is string | null based on the schema. The variables object is type-checked against the operation's variable definitions.

For the TypeScript discipline that catches schema mismatches at compile time, Claude Code with TypeScript covers the type patterns that pair cleanly with codegen output.

Common Claude Code mistakes with urql

Six patterns Claude generates incorrectly without CLAUDE.md constraints.

1. Document cache when entity invalidation matters

Claude generates:

import { createClient, cacheExchange, fetchExchange } from 'urql';

const client = createClient({ url, exchanges: [cacheExchange, fetchExchange] });

The default cacheExchange from urql is the document cache. A mutation that updates a user does not refresh any list query that contains the user. The UI shows stale data.

Correct pattern: cacheExchange from @urql/exchange-graphcache with updates for mutations.

2. Missing dedupExchange

Claude generates exchanges: [cacheExchange, fetchExchange]. Two components mount simultaneously with the same query and the network sees two requests.

Correct pattern: exchanges: [dedupExchange, cacheExchange, fetchExchange].

3. fetchExchange not last

Claude generates exchanges: [fetchExchange, cacheExchange, dedupExchange]. The fetch happens before the cache and dedup can do their job. Every query goes to the network.

Correct pattern: fetchExchange is always last in the chain.

4. Graphcache without keys for non-id types

Claude generates graphcache configuration with updates but no keys. A type with addressId instead of id is stored under the same cache key as every other instance of that type, so changes to one address corrupt all the others.

Correct pattern: keys: { Address: (data) => data.addressId }.

5. Raw gql string instead of typed document

Claude generates:

const [{ data }] = useQuery({ query: gql\`query { posts { id title } }\` });

data is any. Misspelling id as i compiles. The bug appears at runtime.

Correct pattern: write the query in a .graphql file, run codegen, import the TypedDocumentNode.

6. SSR without ssrExchange

Claude generates a Next.js client with the canonical exchanges but no ssrExchange. The server-rendered HTML has the query data, the client mounts without it, and the client refetches immediately, causing a flash of loading state.

Correct pattern: ssrExchange({ isClient: false }) on the server, extract data, hydrate with ssrExchange({ isClient: true, initialState }) on the client.

Add this list to CLAUDE.md with the corrected form for each.

Permission hooks for urql projects

A urql project has CLI surface for codegen, installing packages, and testing. Permission hooks gate the destructive ones.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(pnpm codegen*)",
      "Bash(pnpm graphql-codegen*)",
      "Bash(pnpm add urql*)",
      "Bash(pnpm add @urql/*)",
      "Bash(pnpm test*)"
    ],
    "deny": [
      "Bash(pnpm remove urql*)",
      "Bash(pnpm remove @urql/*)",
      "Bash(rm -rf src/generated*)"
    ]
  }
}

Codegen and adding packages are safe. Removing urql or wiping the generated types needs explicit confirmation.

Building urql clients that cache the right things

The urql CLAUDE.md in this guide produces clients where the exchange order is dedup, cache, auth, subscription, fetch in that order, the cache is graphcache with explicit keys for non-id types and updates for mutations that change cached lists, every query is a TypedDocumentNode from codegen so the result type is inferred, subscriptions go through subscriptionExchange with a module-scoped WebSocket client, and SSR uses ssrExchange to round-trip cache state through Next.js page props.

The underlying principle is the same as any pipeline-driven framework with Claude Code. urql without a CLAUDE.md produces clients that look correct and ship the wrong cache behaviour: list queries that show stale data after mutations because the document cache does not know about entities, double network requests because dedup runs after cache, type-any query results because codegen was not configured, and SSR flicker because the server cache was not handed to the client.

For the React data-fetching mental model urql shares with other clients, Claude Code with React Query covers the staleness and refetch patterns that overlap with urql's exchange pipeline. For the Apollo alternative if your team needs the full feature set, Claude Code with Apollo GraphQL covers the trade-offs that matter when picking a GraphQL client. Claudify includes a urql-specific CLAUDE.md template with the exchange order, the graphcache configuration, the codegen pipeline, the subscription transport, the SSR setup, and all six common-mistake rules pre-configured.

Get Claudify. Ship urql clients that respect the entity model.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame