Knowledge base · Guide
Querying content with GraphQL: one request, exactly the fields you need
- Kind
- Guide
- Published
- 24 Jul 2026
- Reading
- 7 min
NOTE: This is a draft. The graphql API is a work in progress
Applies to: tags and environments, on projects whose releases were built with a type model You will learn: how Coho generates a schema from your own content types, how to keep GraphQL cacheable, and — just as usefully — the four things this schema deliberately will not do
The short version
Every release carries a GraphQL schema generated from your content types. A page type gives you a page query and a pageCollection.
GET /v1/projects/{project}/{ref}/graphql?query={page(slug:"about"){title author{name}}}
Authorization: Bearer <your delivery key>
Use GET. A POST works and is there for long queries, but it throws away every cache between you and Coho, including the 304 that makes repeat reads free.
The schema is yours, and it is per release
Coho does not have one GraphQL schema. It generates one per ref, from that release's type model, so the schema describes exactly the types that release has — with inheritance already folded in, so a subtype carries the fields it inherits.
Introspection is on, so your tooling works normally. Point it at the same URL:
GET /v1/projects/acme/v1.4.6/graphql?query={__schema{types{name}}}
A tag's schema can never change, which makes it the easiest thing in the world to cache: fetch it once at build time and keep it. An environment's schema is whichever tag it points at, so it changes on a promotion.
How your types map:
⚠️ A ref built before type models were published has no schema, and says so. It gets one at its next release.
Keep it cacheable: GET, and persisted queries
A GET carries the ref's own ETag and Cache-Control, and answers If-None-Match with 304 Not Modified — exactly as the REST reads do. Nothing caches a POST: not your browser, not a proxy, not a CDN. So POST is for a query too long for a URL, and the answer to that is usually a persisted query rather than a longer URL.
Send the query once as a POST:
POST /v1/projects/acme/prod/graphql
Content-Type: application/json
{"query":"{page(slug:\"about\"){title}}"}
Then ask for it by its SHA-256, as a cacheable GET:
GET /v1/projects/acme/prod/graphql?q=<sha256-of-the-query-text>
Registration is content-addressed, so sending the same query twice is harmless. If a hash is not registered on the instance you reach, you get a 400 telling you to POST it once — register on start-up and you will not meet it again.
variables and operationName work on both: as a JSON-encoded querystring parameter on a GET, and as fields of the body on a POST.
⚠️ What this schema will not do
This is the part worth reading before you design around it. Coho serves published releases from a sealed index built for exactly these access patterns, and the schema only offers what that index can honestly answer.
No filtering on arbitrary fields. There is no where:, and there will not be one until the index can answer it. A collection takes locale, order, limit, offset, and exact, and that is all. Reading a page and filtering it afterwards would give you wrong counts and wrong paging, so Coho does not offer it rather than offering it broken.
No nested filtering. author { name } is free — it is a reference hop. articles(where: {author: {name: "Jo"}}) is a join the index cannot do.
No mutations and no subscriptions. Delivery is read-only and touches no database. Writes go through the authoring API.
Ordering is still limited to sortable fields. order names an integer, number, or datetime field that the type declares; text and choice fields cannot be ordered on yet.
What GraphQL genuinely buys you here is selection: one request that returns exactly the fields at exactly the depth your page needs, where a REST read returns whole entry bodies. For a page composed from several types, that is often a much smaller response than the equivalent REST calls.
One caveat to set expectations honestly: selection narrows what is sent, not what is read. Asking for one field of one entry still reads that entry's whole stored body on the server. You save bytes on the wire, not work in the index.
Nulls, and why some required fields are nullable
Two rules that will otherwise surprise you:
A localized field is always nullable. Your type may require it, but "required" describes the stored entry, and a locale with no translation leaves the field absent. So title on a localized type is String, not String!.
A reference or an asset is always nullable, required or not. The target may have been deleted, may not be on this ref, or may lie beyond the depth your selection reached. A non-null reference would turn any of those into an error that wipes out the whole selection above it — for data your schema was right about and the release no longer has.
And where a reference cannot be carried at all:
- in a single position it is null;
- in a list it is left out, so
[Page!]stays honest and iterating a list never hands you a hole.
The REST shapes report both facts explicitly in unresolved. GraphQL has nowhere to put them, which is the one place this surface tells you less than the REST one.
The bounds on a query
A query is checked before it executes, and refused with 400 INVALID_QUERY naming the bound it crossed — except for the size of the request itself, which is 413 REQUEST_TOO_LARGE, because the remedy is to send fewer bytes rather than to write a different query:
The last two are refused with RESOLUTION_LIMIT as execution runs. The hop count is measured a little generously — nesting inside an asset's file, for instance, counts toward it — so if you are near the limit, flatten the selection rather than assuming you have hops left.
If an instance is already running as many queries as it will run at once, you get 503 POOL_UNAVAILABLE: retry shortly. GraphQL execution is deliberately bounded so that a heavy query cannot take the rest of delivery down with it.
When Coho says no
Refusals arrive as GraphQL's errors array where the query ran, and as an RFC 9457 problem document where it never started. Either way the code is the delivery API's own, so you branch on the same codes as the REST reads: KEY_REQUIRED, PROJECT_NOT_FOUND, REF_NOT_FOUND, INVALID_QUERY, RESOLUTION_LIMIT, REQUEST_TOO_LARGE, PATH_INVALID, RATE_LIMITED, PLAN_LIMIT, POOL_UNAVAILABLE.
The order matters, and it is the same order as every other read: the key, then the plan's cap, then the rate limit, then the ref, then your revalidation, and only then anything about the query itself. A caller with no key sending a malformed mutation gets 401 — it learns nothing about your schema.
Keys, public environments, rate limits, and CORS all behave exactly as they do for the REST reads: a keyless query works from a browser, a keyed one does not, and a key in browser JavaScript is a published key. See Reading content without an SDK for the detail.
Common questions
Can I use my usual GraphQL client? Yes. It is a normal schema with introspection. Persisted queries follow the convention Apollo Client and urql already speak.
Should I use GraphQL or the REST reads?
Use the typed SDK if there is one for your language — it is the supported, cheapest path. Use GraphQL when a page is composed from several types and you want one request that returns exactly its fields. Use the REST reads with shape=nested when you want something simple to hand to a template.
Is the schema stable enough to generate types from? For a tag, completely: it can never change. Generate at build time against the tag you are releasing. Against an environment, regenerate when you promote.
Why is my field typed JSON?
Either it is a json field, or it is a shape this generator cannot express yet — a repeater holding more than one component type, for instance. Its contents are exactly what the REST reads return for the same field.
A type of mine is missing from the schema.
Two causes. Either its slug collides with another type's once capitalised (page and Page are one GraphQL name), or it declares a field called id, slug, type, or locale — in which case the type is there but all of its fields are under a single fields: JSON!, because those four names belong to the entry itself.
Does GraphQL work on a branch, or a preview? Not yet. It serves tags and environments — released content. Preview and branch reads use the REST delivery contract, which both tiers implement.