Reference › GraphQL
Docs/Reference/GraphQL

GraphQL

What you'll learn

How to add a GraphQL API next to the REST API: the schema it makes from your config, queries, mutations, limits, and how to add your own fields.

Before this page: REST API and Plugins.

@easy-cms/plugin-graphql serves a GraphQL API made from your collections and globals. It reads and writes through the Local API, so access rules, field access, hooks, validation, drafts and localization work as they do over REST. It suits frontends that already use Apollo, urql or Relay, or that want one request with only the fields they need.

Set it up

npm
npm install @easy-cms/plugin-graphql
pnpm
pnpm add @easy-cms/plugin-graphql
yarn
yarn add @easy-cms/plugin-graphql
bun
bun add @easy-cms/plugin-graphql
import { graphqlPlugin } from '@easy-cms/plugin-graphql'
 
export default defineConfig({
  // …
  plugins: [graphqlPlugin()],
})

The endpoint is <routes.api>/graphql, e.g. https://example.com/api/cms/graphql. It takes POST with { "query", "variables", "operationName" }, and GET with ?query= for queries (never mutations). Open it in a browser outside production to get GraphiQL: logged in to the admin, it reads what you may read.

The schema

Each collection and global gets a type and operations, named after its slug:

Collection postsGlobal site-settings
type Posttype SiteSettings
post(id): one documentsiteSettings: the global
posts(where, sort, limit, page): a page of documents
createPost, updatePost, deletePostupdateSiteSettings

Plus me, the logged-in user (or the owner of the API key). The built-in collections are User (user, users), Media (mediaItem, media) and, with folders, MediaFolder. API keys and the collections Easy CMS uses internally are never in the schema.

query Home($category: ID!) {
  posts(where: { category: { equals: $category } }, sort: [publishedAt_DESC], limit: 5, locale: en) {
    docs {
      id
      title
      cover { url alt sizes { thumbnail { url width height } } }
      category { name }
    }
    totalDocs
    hasNextPage
  }
  siteSettings { name }
}

How fields map:

FieldGraphQL
text, textarea, email, slugString
numberFloat
booleanBoolean
dateDateTime (ISO 8601 string)
selectan enum of its options (String when an option isn't a valid GraphQL name); a list with hasMany
relationship, uploadthe related type, so you can select its fields; a list with hasMany
groupan object type
arraya list of row types, each with id
blocksa list of a union of block types: ... on HeroBlock { heading }
richText, jsonJSON (rich text is a Tiptap document)
your own field typesthe type they are built on

Every field can be null, except id, createdAt and updatedAt: a field may be hidden by field access or empty in a locale even when it is required. Hidden fields (hidden: true) are never in the schema. Collections with drafts have status.

Filtering and sorting

where is typed for each collection, with the operators of REST: equals, not_equals, in, not_in, like, exists, and gt, gte, lt, lte for numbers and dates. Combine with AND and OR; fields in a group are nested.

posts(where: {
  OR: [{ tags: { in: [news] } }, { views: { gte: 100 } }]
  seo: { noindex: { equals: false } }
}) { totalDocs }

sort takes a list such as [publishedAt_DESC, title_ASC] (default: newest first). limit is 1 to 100 (default 10), page starts at 1.

Languages and drafts

With localization, queries take locale and fallbackLocale; relationships are read in the same locale. To read several languages at once, use aliases:

{
  th: post(id: "1", locale: th) { title }
  en: post(id: "1", locale: en) { title }
}

draft: true includes drafts, for logged-in users and API keys only, as in REST.

Mutations

mutation {
  createPost(data: { title: "Hello", body: "First paragraph\n\nSecond", category: "3" }, draft: true) {
    id
    status
  }
}
  • create<Type>(data, locale, draft), update<Type>(id, data, locale, draft), delete<Type>(id) return the document; update<Global>(data, locale, draft) for globals.
  • draft: true saves a draft, draft: false publishes; left out, the collection does what it always does.
  • Relationships and uploads take ids. Rich text takes a Tiptap document or plain text (blank lines start new paragraphs). Blocks take rows as JSON with blockType.
  • Required fields are required in create inputs; update inputs change only what you send.
  • Files are uploaded over REST, then used by id. Logging in, versions and scheduling are REST too.

Who may do what

Requests are authenticated like REST: the admin's session cookie, Authorization: Bearer with a session token, or an API key, which may do only what it lists. Browser POSTs pass the same CSRF check as REST.

Errors come in errors with a code in extensions.code, as REST tells them apart:

CodeWhen
UNAUTHORIZEDNot logged in where that is needed.
FORBIDDENNot allowed.
NOT_FOUNDNo such document (updating or deleting).
VALIDATION_ERRORInvalid data; extensions.fields lists each field's problem.
BAD_USER_INPUTA wrong argument, e.g. limit: 500.
QUERY_TOO_DEEP, QUERY_TOO_LARGEOver a limit.

A document you may not see is null, as over REST; a collection you may not read at all gives UNAUTHORIZED or FORBIDDEN. A malformed query is answered with status 400; everything else with 200 and errors.

Limits

GraphQL lets a client ask for a lot in one request, so the plugin caps it:

  • Depth: at most 7 levels of fields (introspection is not counted).
  • Documents: at most 2000 documents loaded per request, relationships included.
  • Lists: limit at most 100.

Relationships are loaded in batches: a list of 100 posts and their categories takes one query for the posts and one for the categories.

Options

OptionDefault
path/graphqlWhere under routes.api the endpoint is.
namesfrom the slugOther names, by slug: { news: { type: 'NewsItem', one: 'newsItem', many: 'news' } }.
exclude[]Collections and globals to leave out, by slug.
limits{ depth: 7, documents: 2000 }
introspectiontrueLet clients read the schema, as codegen and GraphiQL do.
graphiqloutside productionShow GraphiQL to browsers.
extend—Your own queries, mutations and fields.

Two names can clash, e.g. a collection data, whose singular is also data. The app then stops at startup and says which names to choose.

Your own fields

extend adds to the schema with graphql-js objects. Resolvers get a context with cms (the Local API), user and load(collection, id), which batches like the built-in relationships:

import { GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLString } from 'graphql'
 
graphqlPlugin({
  extend: ({ type }) => ({
    query: {
      search: {
        type: new GraphQLList(new GraphQLNonNull(type('Post'))),
        args: { text: { type: new GraphQLNonNull(GraphQLString) } },
        resolve: async (_root, { text }, { cms, user }) =>
          (await cms.find('posts', { user, overrideAccess: false, where: { title: { like: text } } })).docs,
      },
    },
    fields: {
      Post: {
        readingMinutes: {
          type: GraphQLInt,
          resolve: (post) => Math.ceil(JSON.stringify(post.body ?? '').length / 1500),
        },
      },
    },
  }),
})

Pass { user, overrideAccess: false } so your resolvers apply the reader's access rules.

Codegen and other servers

Write the schema to a file for GraphQL Code Generator and similar tools:

npx easy-cms generate:graphql            # schema.graphql
npx easy-cms generate:graphql web/schema.graphql

It opens the database like other commands. Codegen can also read the schema from the running endpoint.

To serve the schema from your own GraphQL server (Apollo Server, Yoga, a gateway), build it and give each request a context:

import { buildGraphQLSchema, createContext } from '@easy-cms/plugin-graphql'
 
const cms = await getEasyCMS()
const schema = buildGraphQLSchema(cms.config)
// For each request:
const contextValue = createContext(cms, await cms.auth.userFromHeaders(request.headers))

Next steps

  • REST API: uploads, logging in, versions and scheduling.
  • Access control: rules that apply to GraphQL too.
  • API keys: keys for scripts and other servers.