# GraphQL clients

Source: https://capacms.com/docs/guides/graphql-clients

Use Apollo Client, urql or plain fetch with Capa's GraphQL endpoint, and keep reads cacheable with GET and persisted queries.

Capa's GraphQL endpoint speaks standard GraphQL over HTTP. Any client works. This guide sets up Apollo Client and urql, and shows how to keep every read cacheable at the CDN.

## The endpoint

| Request                                                       | Notes                                                          |
| ------------------------------------------------------------- | -------------------------------------------------------------- |
| `GET https://cdn.capacms.com/api/graphql?query=…&variables=…` | Cacheable at the CDN with a production key.                    |
| `POST https://cdn.capacms.com/api/graphql`                    | JSON body `{ query, variables, operationName }`. Never cached. |

Every request carries two headers:

```http
x-api-key: cap_live_...
Capa-Version: 2026-10-01
```

The schema is built for the key: it holds exactly the models the key can read. A production key sees published entries, and a development key sees drafts. GraphQL is read-only, and one operation is sent per request. Batching is refused.

Try it with `curl` first:

```bash
curl -G https://cdn.capacms.com/api/graphql \
  -H "x-api-key: $CAPA_KEY" \
  -H 'Capa-Version: 2026-10-01' \
  --data-urlencode 'query=query Latest($first: Int) { articles(first: $first, sort: [publishedAt_DESC]) { nodes { id title author { name } } } }' \
  --data-urlencode 'variables={"first":3}'
```

Want to explore first? Open **Developers > GraphQL** in the admin. The Explorer runs queries with any of your keys and shows the cost and the matching REST request. See [GraphQL Explorer](https://capacms.com/docs/api/graphql-explorer).

## Use GET for anything a CDN should serve

| Request                                    | Cached at the CDN                                  |
| ------------------------------------------ | -------------------------------------------------- |
| GET with a production key, no errors       | Yes, and purged when an entry it read is published |
| GET with a development key                 | No                                                 |
| Any response with `errors`                 | No                                                 |
| GET selecting `me`, `__schema` or `__type` | No                                                 |
| POST                                       | Never                                              |

Configure your client to send queries by GET. Keep a GET URL under 8,192 bytes. Longer documents belong in a POST, or better, in a persisted query (below).

## Apollo Client

```ts
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { relayStylePagination } from "@apollo/client/utilities";

export const client = new ApolloClient({
  link: new HttpLink({
    uri: "https://cdn.capacms.com/api/graphql",
    headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01" },
    useGETForQueries: true,
  }),
  cache: new InMemoryCache({
    typePolicies: {
      Query: { fields: { articles: relayStylePagination(["filter", "sort"]) } },
    },
  }),
});
```

Capa's lists are Relay connections, so Apollo's `relayStylePagination` merges pages for you. Fetch the next page by passing `pageInfo.endCursor` as `after`:

```ts
import { gql } from "@apollo/client";

const ARTICLES = gql`
  query Articles($first: Int, $after: String) {
    articles(first: $first, after: $after, sort: [publishedAt_DESC]) {
      edges { node { id title } }
      pageInfo { hasNextPage endCursor }
    }
  }
`;

const first = await client.query({ query: ARTICLES, variables: { first: 10 } });
```

## urql

```ts
import { Client, cacheExchange, fetchExchange } from "@urql/core";

export const client = new Client({
  url: "https://cdn.capacms.com/api/graphql",
  exchanges: [cacheExchange, fetchExchange],
  preferGetMethod: "within-url-limit",
  fetchOptions: () => ({
    headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01" },
  }),
});
```

In React, pass the same client to urql's `Provider`.

## Persisted queries

A persisted query sends the SHA-256 hash of the document instead of the document. The URL stays short, so every read can be a cacheable GET.

Capa speaks the automatic persisted queries protocol that Apollo and urql use:

1. The client sends the hash alone, by GET.
2. If Capa has the document, it runs it. The CDN can serve the next identical GET.
3. If not, Capa answers `PersistedQueryNotFound`. The client sends the document with the hash, by POST, and Capa runs it.

**A production key never stores a document.** It runs what it is sent, but it does not register it, because it ships in a public bundle. So register your documents at build time with a development key, or every call from your site is a miss followed by an uncached POST.

### With Apollo Client

```ts
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";

export const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: createPersistedQueryLink({ sha256, useGETForHashedQueries: true }).concat(
    new HttpLink({
      uri: "https://cdn.capacms.com/api/graphql",
      headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01" },
    }),
  ),
});
```

### With urql

```ts
import { Client, cacheExchange, fetchExchange } from "@urql/core";
import { persistedExchange } from "@urql/exchange-persisted";

export const client = new Client({
  url: "https://cdn.capacms.com/api/graphql",
  exchanges: [cacheExchange, persistedExchange({ preferGetForPersistedQueries: true }), fetchExchange],
  fetchOptions: () => ({
    headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01" },
  }),
});
```

### Register the hashes your client sends

A client hashes the document it prints, not your source text. Apollo, for one, adds `__typename` to every selection first. So register by running each operation once through the same client setup, with a development key, as a build step.

Registration is a POST with a development key. Send it to the host you read from: `cdn.capacms.com` passes every POST on to the host that stores documents.

```ts
// register-queries.ts: run on every deploy, with a development key.
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
import { ARTICLES, ARTICLE } from "./queries";

const register = new ApolloClient({
  cache: new InMemoryCache(), // the same cache options as the site's client
  link: createPersistedQueryLink({ sha256, useGETForHashedQueries: true }).concat(
    new HttpLink({
      uri: "https://cdn.capacms.com/api/graphql",
      headers: { "x-api-key": process.env.CAPA_DRAFT_KEY!, "Capa-Version": "2026-10-01" },
    }),
  ),
});

for (const query of [ARTICLES, ARTICLE]) {
  // A document that validates is stored even when its variables are refused,
  // so an operation with required variables registers with {} too.
  await register.query({ query, variables: {}, fetchPolicy: "network-only" }).catch(() => undefined);
}
```

`CAPA_DRAFT_KEY` holds a development key, `cap_test_...`. Never ship it to a browser: it reads drafts.

Check that it worked. A GET with the hash alone should answer `data`, not `PersistedQueryNotFound`.

Using the Capa SDK instead? `capa persist` does all of this for you, and pins each build's documents so preview builds never push production's out. See [SDK CLI](https://capacms.com/docs/sdk/cli).

### The rules

* The hash is the lowercase hex SHA-256 of the document exactly as sent. Any change, whitespace included, is a new hash.
* A document is stored only if it parses, validates for the key that sends it, and fits in 32 KB.
* Documents are stored per project, and every key of the project can run one by hash. A document is checked against the calling key's schema each time it runs.
* A project keeps up to 2,000 documents and 16 MiB. Past that, the documents least likely to be needed are dropped first. See [persisted queries](https://capacms.com/docs/api/graphql#persisted-queries).

## Keys in the browser

A key in a browser bundle is public. Use a production `cap_live_` key with the **Read only** starting point, and bind it to your site's origins under **Developers > Keys**. A request from any other origin is refused with [`403 origin_refused`](https://capacms.com/docs/errors/origin_refused).

Never ship a `cap_test_` key. It reads drafts.

## Errors

GraphQL errors arrive in `errors`, each with Capa's code, hint and docs link in `extensions`:

```json
{ "errors": [ { "message": "articles has no field \"subtitle\".",
                "extensions": { "type": "invalid_request", "code": "unknown_field",
                                "hint": "…", "docs": "https://docs.capacms.com/errors/unknown_field" } } ],
  "data": null }
```

Branch on `extensions.code`. A root field that failed is `null`, and the other root fields still carry data. A request refused as a whole, such as a bad key or a query over the cost budget, has `errors` and no `data`.

## Related

* [GraphQL reference](https://capacms.com/docs/api/graphql) for the schema, filters, sorts and [limits](https://capacms.com/docs/api/graphql#limits).
* [TypeScript](https://capacms.com/docs/guides/typescript) for typed documents with `capa-codegen`.
* [Caching](https://capacms.com/docs/concepts/caching) for what a publish purges.
