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

GraphQL plugin

GraphQL over the content API — no schema, no credential, no permission of its own.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

The public content API, spoken as GraphQL. An adapter over the REST services: same bearer tokens, same workspace bucket, same scopes, same visibility rules. A token minted before it existed works unchanged.

POST /api/v1/graphql              queries and mutations (one operation per request)
GET  /api/v1/graphql              the schema as SDL (text/plain)
GET  /api/v1/graphql/playground   GraphiQL — rides the API_DOCS switch

The playground follows the Scalar API reference: on outside production, API_DOCS=true|false overrides. With it off the route does not exist. The page needs no token; paste one into its header editor.

Install and register

npm install @orthacms/content-graphql
// apps/server/src/plugins.ts — after ContentPlugin
import { ContentGraphqlPlugin } from '@orthacms/content-graphql';

const content = ContentPlugin({ types: contentTypes });

ContentGraphqlPlugin({
    content, // by value: two types colliding as GraphQL names fail the boot
    ...config.plugins.contentGraphql,
    playground: config.docs.enabled === true
});

create-orthacms-app asks which protocols to enable. GraphQL is unticked by default; add it later with the two steps above.

Headers

HeaderWhen
Authorization: Bearer orthacms_…Always
Content-Type: application/jsonOn POST
X-Workspace-Id: <uuid>When the token covers more than one workspace
X-Reader-Tags: premium, euTo read as a reader — see segments

A query

Example language
query Latest($locale: String) {
  articles(pageSize: 10, sort: "-publishedAt", locale: $locale) {
      total
      items {
          id
          title
          slug
          publishedAt
          author {
              name
          }
          tags(pageSize: 5) {
              total
              items {
                  name
              }
          }
      }
  }
}
curl -X POST https://cms.example.com/api/v1/graphql \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{"query":"query($locale: String) { articles(pageSize: 10, locale: $locale) { total items { id title } } }","variables":{"locale":"de"}}'
const response = await fetch('https://cms.example.com/api/v1/graphql', {
  method: 'POST',
  headers: {
      Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
      'Content-Type': 'application/json',
      'X-Workspace-Id': process.env.ORTHACMS_WORKSPACE_ID
  },
  body: JSON.stringify({
      query: 'query Latest($locale: String) { articles(pageSize: 10, locale: $locale) { total items { id title slug } } }',
      variables: { locale: 'de' },
      operationName: 'Latest'
  })
});
const { data, errors } = await response.json();

More reads:

# one entry, by id or by translation group + locale
query {
    article(id: "0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90") { title }
    de: article(localeGroupId: "4a7d9e21-3b6c-4f58-a1e0-9c2b8d7f6e53", locale: "de") {
        title
        translations { locale title }
    }
}

# filter, search, drafts (DRAFT and ANY need a full-access token)
query {
    articles(
        search: "autumn"
        filter: { and: [{ field: "featured", op: "eq", value: true }] }
        status: ANY
    ) {
        items { id title status publishedAt }
    }
    contentTypes { name kind label publishable i18n }
}
# the schema your token sees, as SDL
curl https://cms.example.com/api/v1/graphql -H 'Authorization: Bearer orthacms_…'

Names

The schema is generated from your content types. For a type named blog_post:

WhatName
Object / list envelopeBlogPost / BlogPostList ({ items, total })
Single readblogPost(id:) — or localeGroupId: + locale: on a localized type
List readblogPosts(page, pageSize, sort, search, filter, locale, status)
A single typeblogPost(locale:) returns the record — no list
MutationscreateBlogPost, updateBlogPost, publishBlogPost, unpublishBlogPost, deleteBlogPost
InputsBlogPostInput (values), BlogPostRelationsInput (relation deltas)
  • An owning single relation is the target object (author { name }).
  • Many-to-many and inverse relations are { items, total } with page/pageSize.
  • Media fields are lists of { id name url thumbUrl alt … }.
  • A relation to a type the workspace was not granted is omitted from the schema.

Mutations

Need a full-access token.

mutation Draft {
    createArticle(
        input: { title: "Autumn catalogue", slug: "autumn-catalogue" }
        relations: { tags: { link: ["b3e1c7d9-5a2f-4e86-9d10-7c4f2a8b6e31"] } }
        locale: "en"
    ) {
        id
        status        # DRAFT — a create never publishes
        tags { total }
    }
}

mutation Ship {
    publishArticle(id: "0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90") {
        status
        publishedAt
    }
}
  • update… is a partial update: omitted fields are untouched, null clears.
  • Relation deltas take link, unlink, order; set for an owning single relation; by: LOCALE_GROUP to pass translation-group ids.
  • delete… returns true.

Errors

Every executed operation answers HTTP 200. The status lives in errors[].extensions:

{
    "errors": [
        {
            "message": "This token’s scope does not allow content:create.",
            "path": ["createArticle"],
            "extensions": { "code": "FORBIDDEN", "status": 403 }
        }
    ],
    "data": null
}
extensions.codestatus
BAD_REQUEST400
UNAUTHENTICATED401
FORBIDDEN403
NOT_FOUND404
CONFLICT409
VALIDATION_FAILED422 — with extensions.issues, per field
INTERNAL_SERVER_ERROR500 — message masked

data and errors can arrive together: a nullable field that failed comes back null beside the rest.

Budgets

Checked after parsing, before execution. Set in apps/server/config/graphql.ts:

VariableDefaultBounds
GRAPHQL_MAX_QUERY_LENGTH16384Characters of query text, checked before parsing
GRAPHQL_MAX_DEPTH8Nesting depth
GRAPHQL_MAX_FIELDS500Fields selected in total, aliases included
GRAPHQL_MAX_COMPLEXITY1000Sum of pageSize down each nesting path
GRAPHQL_SCHEMA_CACHE_TTL_MS60000How long a built schema is reused
// apps/server/config/graphql.ts
export function contentGraphqlConfig(): ContentGraphqlPluginConfig {
    return {
        limits: {
            maxDepth: readPositiveInt('GRAPHQL_MAX_DEPTH', 8),
            maxComplexity: readPositiveInt('GRAPHQL_MAX_COMPLEXITY', 1000),
            maxFields: readPositiveInt('GRAPHQL_MAX_FIELDS', 500),
            maxQueryLength: readPositiveInt('GRAPHQL_MAX_QUERY_LENGTH', 16_384)
        },
        schemaCacheTtlMs: readPositiveInt('GRAPHQL_SCHEMA_CACHE_TTL_MS', 60_000)
    };
}
  • One operation per request.
  • An id: or localeGroupId: read costs 1, not a page.
  • Introspection is enabled and exempt from the budgets.
  • No rate limit — the REST API has none either.

The schema differs per token

The schema is built per grant set — the content types the token’s workspace was granted. Two tokens can see different SDL, and an ungranted type is a validation error rather than a 404. The cache TTL is freshness only: every resolver re-checks the live grants.

See the GraphQL API for the full delivery guide.