GraphQL Optimization in Payload CMS

In today's modern web development landscape, GraphQL has become a powerful tool for fetching and manipulating data efficiently. When combined with Payload CMS, a headless content management system, it offers developers a flexible and scalable approach to query content.
Payload CMS supports seamless integration with GraphQL. In this tutorial, I'll cover some of the techniques that I've used to leverage GraphQL more effectively.
In this article I'll be covering the following:
- Why use GraphQL with Payload?
- Visual Studio Code and GraphQL
- How to setup Next.js to support GraphQL
- Generate GraphQL schema in Payload CMS.
- Write GraphQL queries for nested relationships.
- How to test out your GraphQL queries.
- When to use GraphQL, REST or Local?
Why use Payload and GraphQL
When I first started using Payload, I felt that REST API's were all I was going to need and as I began to write more complex content, GraphQL seemed more and more like the right tool for the job.
Payload has great support for GraphQL and it provides some benefits:
- More flexibility over what data is returned.
- Payload generates GraphQL schema and types based on Collection configurations.
- Reduces response sizes when multiple relationships and joins are used.
- Supports Mutations which are similar to REST Post and Update.
Optimizing GraphQL on Payload
GraphQL has great benefits but writing queries using strings can become hard to manage and make the experience less enjoyable.
I also felt that writing queries using strings didn't take advantage of the strongly typed GraphQL schema that Payload was generating.
I felt a more optimized strategy should include the following:
- Utilize Payload's generated GraphQL schema.
- Use a Visual Studio plugin for full intellisense.
- Avoid writing string based queries since they can be error prone and harder to maintain and write.
- Import GraphQL queries directly into pages.
- Centralize (or co-locate) queries for better organization rather than completely inline queries.
- Lastly, generate all the needed queries and mutations based on the schema without having to write anything unless needed.
So based on these requirements, I'll walk through some of these strategies to make GraphQL awesome on Payload.
Setting up GraphQL schema
In order to import and generate the Payload GraphQL schema, add the following modules to the package.json
.
pnpm add @payloadcms/graphql graphql-tag -D
Also, add the graphql module as a dependency.
pnpm add graphql
Once installed, the generate command can be called using:
pnpm payload-graphql generate:schema
Let's also add a script in the package.json.
/package.json
{
"scripts": {
"generate:graphql-schema": "payload-graphql generate:schema"
}
}
After running the GraphQL schema, it will output the schema file in the root directory of the project.
Importing GraphQL extensions
In order to actually import a file that contains GraphQL query text we need to configure Next.js to recognize and load graphql files.
Adding the following webpack loader to the next.config.mjs file will instruct the compiler on how to handle these import types.
/next.config.mjs
import { withPayload } from '@payloadcms/next/withPayload'
const nextConfig = {
webpack: (config) => {
config.module.rules.push({
test: /\.(graphql|gql)$/,
exclude: /node_modules/,
loader: 'graphql-tag/loader',
});
return config;
},
}
export default withPayload(nextConfig)
TypeScript and GraphQL modules
Since we're adding a new type of module import, TypeScript is going to complain and show some red squiggly lines in the IDE.
To satsify TypeScript we'll need to add the following type declarations to a *.d.ts file.
In this case I'm just using globals.d.ts but you can name it whatever makes sense or add it to an existing delcaration file.
/globals.d.ts
declare module '*.graphql' {
import { DocumentNode } from 'graphql'
const Schema: DocumentNode
export = Schema
}
declare module '*.gql' {
import { DocumentNode } from 'graphql'
const Schema: DocumentNode
export = Schema
}
Now we need to let TypeScript know where it can find the GraphQL declarations, by adding the path to the include array section of the tsconfig.json configuration.
/tsconfig.json
{
"include": ["global.d.ts"],
}
Visual Studio Code GraphQL support
In order for the VS Code IDE to know how to diplay and handle GraphQL files types, the GraphQL Foundation provides the following extensions.
- GraphQL: Syntax Highlighting
- GraphQL: Language Feature Support
These extensions really make writing GraphQL queries and mutations awesome and much less error prone than using strings.
Point to the Payload GraphQL schema
Now we can finally tie everything together and use the actual Payload generated schema.graphql file.
Add the following configuration snippet to your package.json file.
This is where the GraphQL extensions will look for schema definitions and file extensions that need GraphQL support.
/package.json
{
"graphql": {
"schema": "schema.graphql",
"documents": "**/*.{graphql,gql,js,ts,jsx,tsx}"
}
}
Now all the GraphQL pages will have the following enhancements:
- File icon
- Intellisense and code completion
- Syntax highlighting
Create a Payload Collection
In order to fully demonstrate why GraphQL is an important part of Payload, I'll write a query that accomplishes the following:
- Queries a pages Collection
- Queries any content blocks defined in the layout builder.
- Query all block relationships and media content.
I recently posted an article on how to use Payload's content blocks Maximizing Efficiency: Unlocking the Power of Payload Content Blocks. It's a good starting point since I will be using GraphQL to query Payload blocks in this article rather than REST.
Define a Payload Collection page with blocks
Here's a basic version of the pages Collection which defines two blocks, Banner and Content.
Page Collection with blocks
import type { CollectionConfig } from 'payload'
import Banner from '../../blocks/Banner/config'
import Content from '../../blocks/Content/config'
export const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{
name: 'slug',
type: 'text',
},
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'text',
},
{
name: 'layouts',
type: 'blocks',
required: false,
blocks: [Banner, Content],
},
],
}
Banner blocks definition
The Banner block contains a few relationships and nested links to other pages so it will be a good example of a GraphQL query.
Banner block content example
import type { Block } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
const Banner: Block = {
slug: 'banner',
interfaceName: 'BannerBlock',
fields: [
{
name: 'media',
label: 'Background Image',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'richText',
type: 'richText',
editor: lexicalEditor(),
label: 'Content',
},
{
name: 'links',
interfaceName: 'LinkGroupType',
label: 'Links',
type: 'array',
fields: [
{
name: 'link',
interfaceName: 'LinkType',
type: 'group',
admin: {
hideGutter: true,
},
fields: [
{
name: 'displayText',
label: 'Link Text',
type: 'text',
required: true
},
{
type: 'row',
fields: [
{
name: 'type',
type: 'radio',
defaultValue: 'reference',
options: [
{
label: 'Internal link',
value: 'reference',
},
{
label: 'Custom URL',
value: 'custom',
},
],
},
{
name: 'reference',
type: 'relationship',
label: 'Document to link to',
relationTo: ['pages'],
required: true,
},
{
name: 'url',
type: 'text',
label: 'Custom URL',
required: true,
},
{
name: 'newTab',
type: 'checkbox',
label: 'Open in new tab',
},
],
},
],
},
],
},
],
}
export default Banner
Content blocks definition
This Content block configuration is quite simple but it will show how to query multiple blocks.
Content block example
import type { Block } from 'payload'
import {
lexicalEditor,
} from '@payloadcms/richtext-lexical'
const Content: Block = {
slug: 'content',
interfaceName: 'ContentBlock',
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor(),
label: false,
},
],
}
export default Content;
Writing GraphQL queries
Now that the Collection and blocks are available to query, I'll create the following GraphQL queries:
- Page.gql: Queries for a page by ID.
- PageBySlug.gql: Query for page by slug name, which is based on a where clause.
By holding down the Cmd
key, and clicking on any type in VS Code IDE, it will instantly open up the schema.graphql and point to the definition.
As you build your queries now, the VS Code plugin will show intellisense while typing.
Query Page by ID
query Page($id: Int!, $draft: Boolean) {
Page(id: $id, draft: $draft) {
}
}
Here's the full query for the Page.gql.
/src/payload/graphql/Page.gql
query Page($id: Int!, $draft: Boolean) {
Page(id: $id, draft: $draft) {
id
slug
title
description
layouts {
... on BannerBlock {
id
media {
id
width
height
alt
url
filename
mimeType
thumbnailURL
}
links {
link {
displayText
type
reference {
relationTo
value {
... on Page {
title
description
slug
id
}
}
}
url
newTab
}
}
blockName
blockType
}
... on ContentBlock {
id
richText
blockName
blockType
}
}
}
}
A couple things to note here:
- layouts uses the
... on BannerBlock
and ... on ContentBlock
notation, which is available from the generated schema because each block defines an interfaceName in it's configuration.
- Any relationship fields will have a reference key and can use the
... on <type>
notation.
Here's an example of how Payload structures relationship fields.
Example using ... on
{
reference {
relationTo
value {
... on <type> {
}
}
}
Using the ... on <type>
is very useful when building blocks and relationship queries.
Query Page by slug
Here's the query by slug example. This is useful when you want to find a document page by a more SEO optimized name.
query Page($slug: String, $draft: Boolean) {
Pages(where: { AND: [{ slug: { equals: $slug } }] }, limit: 1, draft: $draft) {
docs {
}
}
}
This example actually uses a where condition.
where: { AND: [{ slug: { equals: $slug } }] }
Anytime, a GraphQL or REST query uses a where condition, its response will return a PaginatedDocs type which contains a docs array and other pagination details.
So we'll also need to add the docs notation in the actual query.
Here's the full PageBySlug.gql query example.
/src/payload/graphql/PageBySlug.gql
query Page($slug: String, $draft: Boolean) {
Pages(where: { AND: [{ slug: { equals: $slug } }] }, limit: 1, draft: $draft) {
docs {
id
slug
title
description
layouts {
... on BannerBlock {
id
media {
id
width
height
alt
url
filename
mimeType
thumbnailURL
}
links {
link {
displayText
type
reference {
relationTo
value {
... on Page {
title
description
slug
id
}
}
}
url
newTab
}
}
blockName
blockType
}
... on ContentBlock {
id
richText
blockName
blockType
}
}
}
}
}
The query by slug is very similar to the query by id with the only difference being the where and docs syntax changes.
Testing GraphQL queries
When running Payload in development mode, it provides the graphql-playground endpoint.
http://localhost:3000/api/graphql-playground
This is an excellent tool to try out your queries to see if they are working and returning the data as you expect.
Here's the GraphQL playground UI for testing.
This tools is already wired up to use the schema.graphql generated by Payload so you can build queries directly inside of this UI interface.
I personally prefer to build my queries in VS Code and then copy/paste them into the playground interface.
Accessing GraphQL from Next.js pages
Here's a couple of Next.js pages that will demonstrate how to use and access Payload's graphql endpoint.
Every GraphQL query is based on a POST request, where the body of the request is the actual query along with variables.
A couple things to note are:
- The request header Content-Type needs to be set to application/json.
- Make sure any variables are the proper primitive JavaScript type that the GraphQL query expects.
- The import statement for each query, will return an AST tree representation of the GraphQL query and needs to be deserialized to a string using the graphql print helper function.
The response objects for each query are a bit different.
Query by ID response format
{
"data": {
"Page": {
}
}
}
The reponse for any where conditions will also send the docs array.
Query by slug response format
{
"data": {
"Pages": {
"docs": []
}
}
}
Next.js page to access by ID
This page will look for the [id] path param in the url.
/src/app/(frontend)/queries/by-id/[id]/page.tsx
import React from 'react'
import Page from '@/payload/graphql/Page.gql'
import { print } from 'graphql'
import { Page as PageType } from '@/payload-types'
type Props = {
params: Promise<{
id?: string
}>
}
export default async function DemoPage({ params: paramsPromise }: Props) {
const { id } = await paramsPromise
let page: PageType = await fetch('http://localhost:3000/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: print(Page),
variables: {
id: Number(id),
},
}),
})
.then((res) => res.json())
.then((res) => {
if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching doc')
return res?.data?.['Page']
})
return <pre>{JSON.stringify(page, null, 2)}</pre>
}
Next.js page to access by slug
This page will look for the [slug] path param in the url.
/src/app/(frontend)/queries/by-slug/[slug]/page.tsx
import React from 'react'
import PageBySlug from '@/payload/graphql/PageBySlug.gql'
import { print } from 'graphql'
import { Page as PageType } from '@/payload-types'
type Props = {
params: Promise<{
slug?: string
}>
}
export default async function DemoPage({ params: paramsPromise }: Props) {
const { slug } = await paramsPromise
let page: PageType = await fetch('http://localhost:3000/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: print(PageBySlug),
variables: {
slug,
},
}),
})
.then((res) => res.json())
.then((res) => {
if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching doc')
return res?.data?.['Pages']?.docs?.[0]
})
return <pre>{JSON.stringify(page, null, 2)}</pre>
}
Automatically Generating GraphQL queries
The last topic worth mentioning is how to generate all the queries and mutations without having to write them by hand.
This is can be useful if you don't need any customizations in your queries. Generated queries will pull all data that is mapped by the schema.
In order to generate the graphql.schema into queries and mutations, we'll need to use a generator.
pnpm add gql-generator -D
Let's also add some package.json scripts to orchestrate the process and do the following:
- generate the Payload schema
- cleanup the generated output folder before re-generating.
- generate the schema to the output folder.
All of the generated code will go into the /src/payload/graphql/gen directory, so it does not interfere with any custom queries in the parent directory /src/payload/graphql.
{
"generate:schema": "payload-graphql generate:schema",
"generate:graphql-clean": "rm -rf ./src/payload/graphql/gen/**",
"generate:graphql-queries": "pnpm generate:graphql-schema && pnpm generate:graphql-clean && gqlg --schemaFilePath ./schema.graphql --destDirPath ./src/payload/graphql/gen --depthLimit 10",
}
Next, just run the following.
pnpm generate:graphql-queries
All of the code will be generated under the output directory in the following subdirectories.
- queries: All queries for the entire schema.graphql file.
- mutations: All mutations to modify any schema in the entire schema.graphql file.
Generated GraphQL output files
└── src
└── payload
└── graphql
├── gen
│ ├── queries
│ ├── mutations
│ └── index.js
└── MyCustomQuery.gql
Simply import the queries into your project from the /gen directory as follows:
import Page from '@/payload/graphql/gen/queries/Page.gql'
In Conclusion
So hopefully this article has offered some improvements to the GraphQL experience when using Payload CMS.
I have found this aproach to using GraphQL very useful for more complex block queries but it can be used for all types of queries.
There is always going to be the case for using an inline string query in GraphQL but having some tools to take the hard part out of writing complex queries is a good thing.
Hope this article has helped to unlock some potential in your projects. Cheers!