GraphQL API Reference
Flexible, type-safe querying and mutation interface with dynamic schema generation. The primary way to query complex relational data for external clients.
On this page
This reference reflects the current implementation. Performance claims are intentionally sourced from the benchmark harness rather than copied numbers — run the suites listed in Testing & benchmarks on your hardware before quoting a figure.
The GraphQL API exposes the CMS content model through a dynamically generated schema and a GraphQL Yoga server with unconditional JIT execution, batching, persisted queries, response caching, and per-tenant schema isolation.
1. At a glance
| Concern | Implementation |
|---|---|
| Endpoint | POST /api/graphql and GET /api/graphql |
| Server | GraphQL Yoga with @envelop/graphql-jit |
| Schema | Generated per tenant from the registered collections and widget definitions |
| Auth | Session cookie, API key (x-api-key / Bearer), website token (x-website-token) |
| Batching | Up to 10 operations per HTTP request |
| Caching | Per-tenant APQ store + response cache + per-tenant schema cache |
| Real-time | SSE (/api/content/events) and Yjs — not GraphQL over WebSocket |
2. Transport
POST
Send a standard GraphQL JSON body:
{
"query": "query { contentSystemHealth { state } }",
"variables": {},
"operationName": "Health"
}
The route accepts query, variables, and operationName. It also accepts APQ fields described in Automatic Persisted Queries.
GET
GET /api/graphql accepts:
| Parameter | Meaning |
|---|---|
query |
GraphQL document |
variables |
JSON-encoded variables object |
extensions |
JSON-encoded extensions (including APQ) |
hash |
APQ SHA-256 digest shorthand |
sha256Hash |
APQ SHA-256 digest shorthand |
GET is a safe method for queries. It still passes through the authentication and publication-filter pipeline.
Batching
The Yoga instance enables HTTP batching with a hard limit of 10 operations. Send a JSON array:
[
{ "query": "query { contentSystemHealth { state } }" },
{ "query": "query { allCollections { _id name } }" }
]
Batched requests bypass APQ resolution and the GraphQL response cache in the current route, because the route parses a single object. Treat batching as a transport-level convenience, not a cached path.
Other verbs
Only GET and POST are exported. PUT, PATCH, and DELETE are not valid GraphQL transport methods here.
Playground
Yoga serves its landing page/playground in non-production environments. Playground CSP is relaxed only when ALLOW_GRAPHQL_PLAYGROUND is not "false"; production keeps a strict CSP. Introspection is still subject to the rules in Limits & security.
3. Authentication & authorization
Credentials
| Caller | Credential | Notes |
|---|---|---|
| Browser session | Session cookie | Normal admin/editor login |
| Machine client | x-api-key: <key> or Authorization: Bearer <key> |
POST requires the key to carry the write scope |
| Website integration | x-website-token: <token> |
Token permissions are enforced by the shared authorization pipeline |
Anonymous guest semantics
A request with no credential is converted into an ephemeral guest for /api/graphql unless x-test-secret is present. The guest role is guest with isAnonymous: true and a read-oriented permission set (collections:read, api:collections, api:media, media:read, graphql:read, api:graphql). An invalid credential is not promoted — it returns 401.
This means GraphQL read queries can work without a session. Mutations and per-resolver privileged operations still require a real user with the relevant permission.
CSRF
Every authenticated POST /api/graphql must pass either:
- the same-origin fast path, or
- the double-submit token pair
X-CSRF-Token+csrf_token/__Host-csrf_tokencookie.
CSRF is exempt for GET, server-to-server API-key/token callers, and TEST_MODE.
Per-resolver permissions
GraphQL does not use the REST ENDPOINT_PERMISSIONS map for the dedicated route; authorization is enforced inside resolvers and the surrounding hook pipeline.
| Surface | Requirement |
|---|---|
me, collection queries |
Authenticated locals.user (guest has read access) |
users |
user:read |
| Media queries | media:read |
contentSystemDiagnostics, metrics, validation |
Administrator |
| Data-operation resolvers | Administrator or the specific operation permission |
| Subscriptions | Authenticated ctx.user |
SEO redirect |
Tenant-scoped read; no additional permission gate |
Permission failures inside a resolver surface as ordinary GraphQL errors in a 200 response, not as HTTP 403.
CORS
Yoga’s own CORS is disabled. The shared security pipeline applies the configured origin allowlist; the Origin header is never reflected blindly.
4. Tenancy & publication visibility
Tenant resolution
The active tenant comes from the host/session/turbo context. In TEST_MODE, x-tenant-id and x-test-tenant-id can override it.
Tenant isolation is applied to:
- the generated schema (one schema per tenant, bounded LRU of 32),
- APQ keys (
apq:{sha256}), - response-cache keys,
- loaders and collection queries.
Collection resolvers reject a request when the server tenant, context tenant, and user tenant disagree (multi-tenancy enabled).
Publication filter
Privileged callers (system/admin/editor, or a user with content:read_drafts / collections:read_drafts / admin) may request published, draft, or all (default all). Everyone else is clamped to published.
Request it with either:
?publicationFilter=published|draft|allx-publication-filter: published|draft|all
5. Schema model
Generation
The schema is generated from contentSystem.getCollections(tenantId) plus each widget’s GraphqlSchema() definition. Test/benchmark collections (bench_*, test-*, test_*, and mock scan artifacts) are filtered out unless the benchmark runtime is active.
Type and field naming
Root collection fields and object types use createCleanTypeName():
- PascalCase the collection name.
- Prefix
_when the name starts with a digit. - Append
_<id-prefix>only when the normalized name and the collection_iddiffer.
Examples:
Collection _id |
Collection name |
GraphQL root field / type |
|---|---|---|
posts |
Posts |
Posts |
blog-posts |
Blog Posts |
BlogPosts |
posts |
Articles |
Articles_posts |
Discover collections and their metadata with allCollections (it returns _id, name, slug, icon, description, fieldCount; it does not return the generated GraphQL type name).
Base fields
Every generated collection type includes, when absent from the schema:
_id, status, createdAt, updatedAt, createdBy, updatedBy — all String.
Widget type mapping
| Widget | GraphQL type |
|---|---|
| Input, Email, Slug, Select, Radio, DateTime | String |
| Number, Currency | Float |
| Checkbox | Boolean |
| Rating | Int |
| Tags | [String] |
| BlockBuilder | [JSON] |
| MediaUpload | MediaImage / [MediaImage] |
| Relation | Target collection type (fallback String) |
| RichText, Geolocation, Price, RemoteVideo | Custom generated types |
Relations and inverse relations
- A
Relationfield resolves the target document through a request-scopedBatchLoader. - A multiple relation resolves an array.
- Inverse relation fields are generated as
<InverseType>(where: JSON): [<OtherType>].
Schema cache
- One schema per root adapter + tenant.
- Invalidated when
contentSystem.versionchanges or_refreshSchema()is called. - Bounded to 32 tenants.
- A failed build is purged so the next request retries.
- The schema is pre-warmed at boot.
6. Queries
Collection query signature
Posts(
pagination: PaginationInput
limit: Int
page: Int
where: JSON
orderBy: String
take: Int
): [Posts]
Resolution order for the page size: take, then pagination.limit, then limit. The default is 50 and the maximum is 200. page (or pagination.page) is 1-based.
Collection queries return an array only. There is no total, pageInfo, or cursor for regular collections. Virtual collections expose total, nextCursor, and cursorOffset.
Filtering (where)
where is a JSON scalar passed to the collection filter engine. Supported operators:
eq, ne, in, nin, gt, gte, lt, lte, contains, some, none, every.
Logical operators:
AND, OR, NOT, $and, $or, $not.
Example:
query PublishedPosts {
publishedPosts: Posts(where: { status: "publish" }, orderBy: "title", limit: 5) {
_id
status
title
}
}
The Yoga-bypass fast path handles flat, single-root collection queries with no relations. In that path only limit, page, sort, sortDirection, and filter are folded into the underlying query; where, orderBy, and take are ignored. Use an alias (as above) or a nested selection when those arguments must be honored, or wait for the fast-path fix.
Sorting (orderBy)
Accepted forms:
field, +field, -field, field:asc, field:desc, field asc, field desc.
Only one sort term is supported. The field must be declared on the collection and orderable (isOrderable); unknown fields are rejected. The current lookup only scans declared collection fields, so some base/system fields may be rejected.
Pagination
query PageTwo {
Posts(pagination: { page: 2, limit: 20 }) {
_id
title
}
}
Variables work for the input object:
query Page($pagination: PaginationInput) {
Posts(pagination: $pagination) {
_id
}
}
{ "pagination": { "page": 2, "limit": 20 } }
Relation population
Only nested selections trigger relation loading. A flat query stays on the fast path.
query PostsWithAuthor {
Posts(limit: 5) {
_id
title
author {
_id
}
}
}
Multiple relations return arrays; media fields resolve to MediaImage / [MediaImage].
Inverse relations
query AuthorsWithPosts {
Authors(limit: 5) {
_id
Posts(where: { status: "publish" }) {
_id
}
}
}
Inverse-relation where is evaluated in memory by the schema model.
System queries
| Field | Notes |
|---|---|
contentSystemHealth |
state, nodeCount, collectionCount, cacheAge, version |
contentSystemDiagnostics |
Administrator only |
contentSystemMetrics |
Administrator only |
validateContentStructure |
Administrator only |
collectionStats |
Per-collection statistics |
allCollectionStats |
All collection statistics |
entries |
Requires collection + limit |
navigationStructure |
Navigation tree |
nodeChildren |
Children of a structure node |
breadcrumb |
Breadcrumb for a path |
Media queries
mediaImages, mediaDocuments, mediaAudio, mediaVideos, mediaRemote — all require media:read.
mediaFolders is declared in the schema but has no resolver in the current release.
Users
me returns the current user. users(pagination: PaginationInput) requires user:read and defaults to a limit of 10. Unlike collection queries, the users resolver does not clamp the limit to 200.
SEO
redirect(from: String!): Redirect reads tenant-scoped redirects.
Virtual collections and data operations
virtualCollectionsvirtualCollection(slug, limit, offset, cursor, include, bypassCache)virtualEnrich(slug, keys, field)configStatus,backupList,channelList,migrationStatus
Localization caveat
Resolvers call getLocalizedValue(parent[field], ctx.locale), but the Yoga context does not currently set locale, so localized values resolve to the default (en). Do not rely on GraphQL for per-locale projection in this release.
7. Mutations
There are no generated per-collection createX / updateX / deleteX mutations. Mutations are limited to collection actions and administrative operations.
| Mutation | Purpose |
|---|---|
runCollectionAction |
Run an action declared by a collection (collectionId, action, entryId, input) |
createVirtualEntry |
Create a virtual collection entry |
updateVirtualEntry |
Update a virtual collection entry |
deleteVirtualEntry |
Delete a virtual collection entry |
configExport |
Export configuration package |
configPlan |
Plan a configuration change |
configApply |
Apply a configuration change |
contentExportValidate |
Validate a content export |
contentExportRun |
Run a content export |
contentImportValidate |
Validate a content import |
backupCreate |
Create a backup |
backupRestore |
Restore a backup |
migrationPlan |
Plan a migration |
migrationApply |
Apply a migration |
contentSyncPush |
Push content to a remote environment |
contentSyncPull |
Pull content from a remote environment |
Administrative mutations require an administrator. runCollectionAction delegates to LocalCMS.collections.runAction() and enforces the collection’s declared action and permissions.
Mutation replay protection
When a client sends x-idempotency-key or x-mutation-id, a duplicate mutation within 5 minutes is rejected with HTTP 409. Without either header, replay detection is not applied.
8. Variables, fragments, and batching
Variables and fragments are standard GraphQL. Use variables for pagination and filters rather than interpolating strings.
operationName is forwarded when the POST body contains a full query. APQ hash-only requests are rebuilt as { query, variables }, so operationName and arbitrary extensions are dropped. Avoid multi-operation documents on the APQ cache-hit path.
Batching is limited to 10 operations and bypasses APQ/response caching as noted in Transport.
9. Automatic Persisted Queries (APQ)
APQ follows the standard three-step handshake.
- Miss — send only the hash. If it is not stored, the gateway returns
PERSISTED_QUERY_NOT_FOUNDand registers nothing. - Register — send the query plus the hash. The gateway verifies
sha256(query)against the hash using a constant-time comparison, then stores the query per tenant. - Hit — send only the hash; the stored document executes.
Accepted key locations:
extensions.persistedQuery.sha256Hashextensions.persistedQuery.hash- top-level
hash - GET
extensions,hash, orsha256Hash
Rules:
- The key must be exactly 64 hex characters.
- A mismatched digest returns
PERSISTED_QUERY_HASH_MISMATCH; a malformed key returnsPERSISTED_QUERY_HASH_INVALID. - Stored under
apq:{sha256}in the shared cache with a 7-day TTL, taggedgraphql/apq. - Keys are tenant-scoped.
Example for the exact document query { contentSystemHealth { state } }:
sha256 = 2bc7e01cee3ed7f70d4e8ed0ced29590381154f0b7f011de7d31a7b6df31c7ca
Miss:
{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "2bc7e01cee3ed7f70d4e8ed0ced29590381154f0b7f011de7d31a7b6df31c7ca"
}
}
}
{
"errors": [
{
"message": "PersistedQueryNotFound",
"extensions": { "code": "PERSISTED_QUERY_NOT_FOUND" }
}
]
}
Register:
{
"query": "query { contentSystemHealth { state } }",
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "2bc7e01cee3ed7f70d4e8ed0ced29590381154f0b7f011de7d31a7b6df31c7ca"
}
}
}
Hit: send the hash-only body again and receive the normal data envelope.
10. Performance & caching
Execution
- JIT —
@envelop/graphql-jitis always active with a bounded 1000-entry LRU per Yoga app. Introspection operations skip JIT. - Parse cache — parsed ASTs are cached up to 1000 entries.
- Cost cache — 500 current + 500 previous entries.
- Validation cache — valid documents skip re-validation until the schema epoch changes.
- Request-scoped loaders —
collectionLoader,userLoader,mediaLoader, and inverse-relation loaders batch by primary key and eliminate N+1 loops.
Response cache
- Key = raw query string + deep-sorted variables + publication filter + user + tenant.
- TTL = 60 seconds.
- Only read-only queries are cacheable.
- Skipped for mutations, subscriptions, error-shaped bodies, bodies containing an empty array, and responses whose resolver
Cache-Controlstarts withprivate. - Concurrent identical reads are coalesced (up to 64 in flight).
The key uses the raw query string, not a normalized document. Changing whitespace or comments produces a different cache entry.
Cache headers and bypass
| Header / parameter | Meaning |
|---|---|
X-Cache: MISS |
Executed and cached |
X-Cache: HIT |
Served from the GraphQL response cache |
X-Cache: TURBO-HIT |
Served by the turbo GET hook before the route |
ETag |
Present on cached responses |
Cache-Control: private, no-store |
GraphQL cache hits |
Cache-Control: private, must-revalidate |
Turbo GET hits |
?refresh=true |
Bypass the response cache |
?nocache=true |
Bypass the response cache |
?bypassCache=true |
Bypass the response cache |
The GraphQL path emits an ETag but does not currently honor If-None-Match to return 304.
Fast path
Three system queries (contentSystemHealth, allCollections, allCollectionStats) have an in-memory fast path with a 2-second JSON cache (64 entries). Flat single-root collection queries with no relations can also bypass Yoga and stream list results.
The fast path is the reason for the where / orderBy / take caveat in Filtering.
Benchmarks
Measure changes with the existing harness rather than quoting numbers from this page:
tests/benchmarks/graphql-api-performance.test.tstests/benchmarks/graphql-cache-hit.test.tstests/benchmarks/graphql-local-sdk.test.tstests/benchmarks/graphql-stress.test.ts
The canonical local commands are bun run test:unit for logic and bun run benchmark / bun run scripts/benchmark-matrix/index.ts for the recorded matrix. Set BENCHMARK_RECORD=1 when producing fresh reports under docs/project/benchmarks/.
11. Limits & security
Two enforcement layers
| Layer | Limits |
|---|---|
| Pre-Yoga security hook | 100 KB query/body cap, brace depth 12, complexity 1000 |
| GraphQL validation plugin | Depth 8, aliases 15, query cost 1000 |
The pre-Yoga layer rejects oversized/complex documents with HTTP 400. The GraphQL layer returns QUERY_TOO_EXPENSIVE in a standard GraphQL error envelope.
Introspection
Introspection (__schema, __type) is blocked when any of the following is true:
NODE_ENV=productionBLOCK_GRAPHQL_INTROSPECTION=trueDEMO_MODE=true
Other protections
- Global WAF/security response service and rate limiter run before the route.
- Mutation replay detection with idempotency headers.
- Strict CSP in production; relaxed playground CSP only in non-production.
- CORS via the shared origin allowlist.
- Hint suppression in GraphQL error responses.
12. Errors
| Case | Envelope |
|---|---|
| Validation/execution error | { "errors": [{ "message", "locations"?, "path"?, "extensions"? }], "data"? }, usually HTTP 200 |
| Cost budget exceeded | HTTP 200, extensions.code = "QUERY_TOO_EXPENSIVE" |
| APQ miss | HTTP 200, extensions.code = "PERSISTED_QUERY_NOT_FOUND" |
| APQ malformed hash | HTTP 200, extensions.code = "PERSISTED_QUERY_HASH_INVALID" |
| APQ digest mismatch | HTTP 200, extensions.code = "PERSISTED_QUERY_HASH_MISMATCH" |
| Mutation replay | HTTP 409, GraphQL error envelope |
| Pre-route auth/CSRF/size reject | REST envelope { success: false, message, code, issues? } |
| Resolver permission failure | GraphQL error in HTTP 200 |
Examples:
{
"errors": [
{
"message": "Query exceeds maximum allowed depth of 8. Current depth: 9 at field \"me\". Reduce nesting or contact support."
}
]
}
{
"errors": [
{
"message": "GraphQL query cost (1550) exceeds the maximum allowed budget (1000). Reduce the number of requested fields, limit nesting depth, or use pagination for connection fields.",
"extensions": { "code": "QUERY_TOO_EXPENSIVE" }
}
]
}
{
"success": false,
"message": "Security violation: CSRF token required",
"code": "CSRF_VIOLATION"
}
13. Real-time
The schema declares subscription fields (contentStructureUpdated, entryUpdated, onPing), and every subscribe handler requires an authenticated user and filters cross-tenant payloads.
However, GraphQL over WebSocket is not shipped. graphql-ws / graphql-sse are not dependencies, and no GraphQL subscription transport is configured. Production live updates use:
- SSE —
GET /api/content/eventsstreams EventBus events. - Yjs —
/api/collaboration/yjs(SSE) plus the optional WebSocket sync server atws://[domain]/ws.
See Real-Time Updates for client integration.
14. Environment & request configuration
| Name / header | Effect | Default |
|---|---|---|
NODE_ENV |
production blocks introspection and applies strict CSP |
unset |
BLOCK_GRAPHQL_INTROSPECTION |
"true" blocks introspection in any environment |
unset/false |
DEMO_MODE |
"true" also blocks introspection |
unset/false |
ALLOW_GRAPHQL_PLAYGROUND |
Non-production: "false" removes relaxed playground CSP |
enabled |
TEST_MODE |
Skips GraphQL CSRF/replay checks and enables test tenant headers | unset/false |
BENCHMARK_DEBUG |
Logs collection registration details | unset/false |
SVELTY_L1_MAX_MB |
List-tier L1 response-cache byte budget | hardware profile, floor 16 MB |
SVELTY_L1_POINT_MAX_MB |
Point-read L1 byte budget | hardware profile, floor 8 MB |
x-publication-filter / ?publicationFilter= |
Requested publication filter, clamped for unprivileged users | all privileged / published otherwise |
x-idempotency-key / x-mutation-id |
Enables 5-minute mutation replay detection | unset |
X-CSRF-Token + csrf_token cookie |
Double-submit CSRF for authenticated POST | required on cookie auth |
x-api-key / Bearer |
API-key auth | — |
x-website-token |
Website-token auth | — |
Hard-coded limits
| Knob | Value |
|---|---|
| GraphQL depth | 8 |
| GraphQL aliases | 15 |
| GraphQL cost budget | 1000 |
| Pre-Yoga query length | 100 KB |
| Pre-Yoga brace depth | 12 |
| Pre-Yoga complexity | 1000 |
| Batching | 10 operations |
| JIT cache | 1000 entries |
| Parse cache | 1000 entries |
| Cost cache | 500 + 500 entries |
| Schema cache | 32 tenants |
| Response-cache TTL | 60 s |
| APQ TTL | 7 days |
| In-flight coalescing | 64 |
| Collection page size | default 50, max 200 |
| Users default limit | 10 (no max clamp) |
| Media default limit | 50 (no max clamp) |
| Virtual collection default | 25 |
| Collection catalog cache | 5 s |
15. Known implementation gaps
These are current behaviors, not recommended patterns:
mediaFoldersis declared but has no resolver.contentSystemDiagnosticsrequires non-nullmaps/cachefields, but the health resolver does not return them.usersand media pagination are not clamped toMAX_PAGE_SIZE.- Localized fields always resolve to
enbecause the Yoga context does not setlocale. - The fast path ignores
where,orderBy, andtakeon flat single-root collection queries. - APQ hash-only rebuilds drop
operationNameand arbitrary extensions. - Batched requests bypass APQ, response caching, and replay keying.
- GraphQL cache hits do not return
304forIf-None-Match. - Data-operation resolvers accept a caller-supplied
tenantIdwithout comparing it toctx.tenantId(most are administrator-only).
16. Testing & benchmarks
Relevant suites:
tests/unit/api/graphql-apq.test.tstests/unit/api/graphql-security.test.tstests/unit/graphql/tests/integration/api/graphql.test.tstests/benchmarks/graphql-*.test.ts
Run logic tests with bun run test:unit. Run the recorded performance matrix with bun run benchmark or bun run scripts/benchmark-matrix/index.ts; use BENCHMARK_RECORD=1 to write fresh reports.