Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

GraphQL

GraphQL plugin @orthacms/content-graphql

The same API in a second protocol — headers, variables, naming, mutations and cost limits.

Documents 0.13.1 Updated Edit this page Report a problem

On this page
POST /api/v1/graphql              run one query or mutation
GET  /api/v1/graphql              the schema, as SDL
GET  /api/v1/graphql/playground   GraphiQL (when API_DOCS is on)

Same tokens, same workspace bucket, same scopes and visibility rules as REST. It calls the same services, so the answers agree.

Sending a request

A JSON POST with query, optional variables, and operationName when the document holds more than one operation. Headers are the REST ones — see request headers.

Example language
const query = `
  query Homepage($locale: String, $pageSize: Int) {
      articles(pageSize: $pageSize, sort: "-publishedAt", locale: $locale) {
          total
          items { id title slug }
      }
  }
`;

const response = await fetch('https://cms.example.com/api/v1/graphql', {
  method: 'POST',
  headers: {
      Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
      'X-Workspace-Id': process.env.ORTHACMS_WORKSPACE_ID, // multi-workspace tokens
      'X-Reader-Tags': 'premium',                          // optional, server-side only
      'Content-Type': 'application/json'
  },
  body: JSON.stringify({
      query,
      variables: { locale: 'de', pageSize: 6 },
      operationName: 'Homepage'
  })
});
const { data, errors } = await response.json(); // HTTP 200 whenever it ran
curl -X POST 'https://cms.example.com/api/v1/graphql' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90' \
-H 'Content-Type: application/json' \
-d '{
  "query": "query Homepage($locale: String) { articles(pageSize: 6, locale: $locale) { total items { id title slug } } }",
  "variables": { "locale": "de" }
}'
POST /api/v1/graphql HTTP/1.1
Host: cms.example.com
Authorization: Bearer orthacms_…
X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90
Content-Type: application/json

{"query":"query Homepage($locale: String) { articles(locale: $locale) { total items { id title } } }","variables":{"locale":"de"}}
{
    "data": {
        "articles": {
            "total": 42,
            "items": [
                { "id": "9c4b…", "title": "Hallo, Postgres", "slug": "hallo-postgres" }
            ]
        }
    }
}
  • X-Workspace-Id also picks the schema: you see that workspace’s types.
  • X-Reader-Tags applies to every nested relation too. Send it only from a backend.
  • No Origin check and no CORS: call it from your server, not a browser.

GraphiQL

/api/v1/graphql/playground is served outside production, or with API_DOCS=true. The page needs no token; paste headers into GraphiQL’s header editor:

{
    "Authorization": "Bearer orthacms_…",
    "X-Workspace-Id": "3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90"
}

A full query

Example language
query Homepage($locale: String, $filter: JSON) {
  articles(pageSize: 6, sort: "-publishedAt", filter: $filter, locale: $locale) {
      total
      items {
          id
          localeGroupId
          title
          slug
          author {
              name
          }
          tags(pageSize: 5) {
              total
              items {
                  name
              }
          }
          coverImage {
              url
              alt
          }
          translations {
              locale
              slug
          }
      }
  }
}
{
  "locale": "en",
  "filter": { "and": [{ "field": "featured", "op": "eq", "value": true }] }
}

Naming

A content type called blog_post becomes:

ThingName
Object typeBlogPost
One entryblogPost(id:), or blogPost(localeGroupId:, locale:) on a localized type
A page of entriesblogPosts(page:, pageSize:, sort:, search:, filter:, locale:, status:) → BlogPostList { items total }
Write inputBlogPostInput, plus BlogPostRelationsInput when it has relations
MutationscreateBlogPost, updateBlogPost, publishBlogPost, unpublishBlogPost, deleteBlogPost
  • A single type gets one field, homePage(locale:, status:), returning the record or null.
  • publish… / unpublish… exist only on publishable types.
  • Plurals follow English rules (category → categories). Check the SDL.
  • contentTypes { name kind label publishable paranoid i18n } lists readable types with the flags SDL cannot express.

Arguments and fields

WhereWhat
List fieldspage, pageSize (max 100), sort ("-publishedAt"), search, filter (a JSON tree — see filtering), locale, status
status:PUBLISHED (default), DRAFT, ANY. The last two need a full token.
Many-to-one relationThe target object, e.g. author { name }. null when unset or not visible.
Many-to-many / inverse relationtags(page:, pageSize:) { items total }. pageSize defaults to 20.
Media fieldA list of MediaAsset — id name url thumbUrl previewUrl kind mimeType alt decorative tracks — with limit: (default 20).
translationsThe other published locale rows, on localized types.

Selecting { id title } narrows the SQL projection exactly like REST’s ?fields=. Fragments are followed.

Where it differs from REST

RESTGraphQL
A single type is a list; take items[0]homePage(locale:) returns the record
Relations need ?relations=previewSelect them; any depth, within the cost limits
{ items, total, page, pageSize }{ items, total }
"published"PUBLISHED
DELETE → 204deleteArticle → true
A missing entry is a 404A missing entry is null
Errors use HTTP statusHTTP 200, status in errors[].extensions.status — see errors

Mutations

Need a full token. input holds the field values directly (no values wrapper); relation changes go in relations. Bulk and access routes have no GraphQL counterpart.

Example language
mutation CreateArticle($input: ArticleInput, $relations: ArticleRelationsInput, $locale: String) {
  createArticle(input: $input, relations: $relations, locale: $locale) {
      id
      status
      localeGroupId
  }
}
{
  "locale": "en",
  "input": { "title": "Hello", "slug": "hello" },
  "relations": { "tags": { "link": ["1f9a…"] } }
}
mutation {
  publishArticle(id: "9c4b…") {
      id
      status
      publishedAt
  }
}
  • A create always lands as a draft on a publishable type.
  • update…, publish…, unpublish… and delete… take id, or localeGroupId + locale on a localized type.
  • Merge semantics: an omitted field is left alone, an explicit null clears it.
  • In relations: link, unlink, order, set (owning single relation), and by: LOCALE_GROUP to pass translation-group ids.
  • A mutation’s result can select its relations, even on a draft.
  • A media field takes an asset id (a list of ids when multiple). A per-usage alt can only be written over REST — see the media API.
  • Updating a published entry keeps its published version live until you publish again.

A schema per workspace

The schema is built per workspace grant set:

  • A type the workspace cannot read is not in the schema. Naming it is a validation error, not a 404.
  • A relation to an unreadable type is omitted from the object and the input.
  • A type the workspace reads only from a shared workspace is queryable; its mutations fail with FORBIDDEN (status 403).
  • Two tokens can get different SDL. That is correct.

The schema is cached per grant set for a minute (GRAPHQL_SCHEMA_CACHE_TTL_MS). Every resolver re-checks live grants, so a stale schema can describe a revoked type but never read it.

Fetch the SDL for codegen:

curl https://cms.example.com/api/v1/graphql \
  -H 'Authorization: Bearer orthacms_…' \
  -H 'X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90' > schema.graphql

See generating a typed client.

Cost limits

Checked after parsing, before execution.

LimitDefaultEnv varCatches
maxQueryLength16384GRAPHQL_MAX_QUERY_LENGTHHuge documents, before parsing
maxDepth8GRAPHQL_MAX_DEPTHDeep nesting
maxFields500GRAPHQL_MAX_FIELDSMany selections or aliases
maxComplexity1000GRAPHQL_MAX_COMPLEXITYPage sizes multiplied down the nesting
Operations per request1—Several operations without operationName
# .env
GRAPHQL_MAX_DEPTH=6
GRAPHQL_MAX_COMPLEXITY=500

Or in code:

ContentGraphqlPlugin({
    content,
    limits: { maxDepth: 6, maxComplexity: 500 }
});

Over budget:

{
    "errors": [
        {
            "message": "Query is 9 levels deep; the limit is 8. Split it, or fetch the deeper records in a second request.",
            "extensions": { "code": "GRAPHQL_LIMIT_EXCEEDED" }
        }
    ]
}
  • Complexity assumes every list comes back full. A field with id: or localeGroupId: counts as one record.
  • Introspection is on and exempt from the budget.

Nested reads batch per level

Nested fields in one execution are batched into one query per level, not one per row.

Page two of a relation is not batched

page: 2 or later on a relation field inside a list costs one query per row. Prefer a larger pageSize on page 1.