In recent years, there has been a consistent rise in demand for headless solutions, from e-commerce to content management. We will focus on Strapi, an open-source headless CMS, and break down how to quickly build and customize tailored headless CMS solutions.
In this article, you will learn:
The term headless comes from the idea of chopping the head (the frontend) from the body (the backend). A headless CMS is focused on storing and delivering structured content—it doesn't really care where and how the content is displayed.
Headless CMS systems have many uses, including:
Strapi is an open-source, Node.js-based headless CMS that saves developers time while giving them freedom to use their favorite tools and frameworks. Strapi also enables content editors to streamline content delivery (text, images, video, etc.) across any device. – Strapi | What is Strapi
Strapi offers the following advantages:
GraphQL is an open-source data query and manipulation language for APIs and a runtime for fulfilling queries with existing data. GraphQL was developed internally by Facebook in 2012 before being publicly released in 2015. – Wikipedia
Unlike REST, GraphQL allows you to retrieve only the content needed. This gives the client a lot more freedom, resulting in much faster development compared to REST.
For this article, let’s use one of the many Strapi Starters as your starting point. You’ll then customize it to suit your needs, in this case with the NextJS Blog Starter. Start by creating a brand-new project:
npx create-strapi-starter graphql-blog next-blog --quickstart
cd graphql-blog
Next, validate that the Strapi installation worked correctly by running:
yarn develop
Strapi will require you to generate an admin account on the initial run, like so:
Next, you should be able to see your Strapi admin fully set up in the context of blog:
This starter should have GraphQL installed by default, If not. You can easily enable GraphQL support directly from the Strapi admin:
In your terminal paste the command, install and restart. You can manually restart the server to make sure the GraphQL plugin is fully initialized—you can do this from the terminal as before:
yarn develop
Once the server has restarted, you can test your new GraphQL API by opening the GraphQL playground: localhost:1337/graphql
.
Next, type the following query to validate that you can retrieve articles:
1query {
2 articles {
3 data {
4 id
5 attributes {
6 title
7 description
8 }
9 }
10 }
11}
You should see the results on the right:
By default, the Strapi GraphQL plugin has Shadow CRUD enabled, a useful feature eliminating the need to specify any definitions, queries, mutations, or anything else. Shadow CRUD will automatically generate everything needed to start using GraphQL based on your existing models. However, this auto-generated implementation might not be enough for every use case. It’s likely you’ll have to customize your queries and mutations for your specific use case. Next, let's look at how you can use custom resolvers to customize both your queries and mutations.
Resolvers are functions that resolve a value for a type or a field in a schema. You can also define custom resolvers to handle custom queries and mutations. Unlike Strapi v3, where we wrote our custom resolvers in the schema.graphql.js
file, things in v4 look slightly different.
In Strapi v3, GraphQL resolvers are either automatically bound to REST controllers (from the core API) or customized using the ./api/<api-name>/config/schema.graphql.js
files. In Strapi v4, GraphQL dedicated core resolvers are automatically created for the basic CRUD operations for each API. Additional resolvers can be customized programmatically using GraphQL’s extension service, accessible using strapi.plugin(’graphql’).service(’extension’)
.
You can learn more about the diferences here. v3/v4 Strapi GraphQl Resolvers
Let's start with a simple example to learn how to query an article via slug instead of an id.
In your GraphQL playground localhost:1337/graphql
run the following query:
1 query {
2 article(id: "1") {
3 data {
4 id
5 attributes {
6 title
7 description
8 content
9 }
10 }
11 }
12 }
As you can see, we query our article by the id.
And return the following data:
1 {
2 "data": {
3 "article": {
4 "data": {
5 "id": "1",
6 "attributes": {
7 "title": "What's inside a Black Hole",
8 "description": "Maybe the answer is in this article, or not...",
9 "content": "Well, we don't know yet..."
10 }
11 }
12 }
13 }
14 }
If we query the article via the slug, it will not work because our current resolver does not yet support this functionality.
Let's look at how we can extend our article resolver to add this functionality.
We can customize our resolvers by using GraphQL's extension service.
Let's take a look inside our index.js file at backend/src/index.js
.
Normally, our file will look like this.
But in our current starter project, it should look like the image below.
We will configure our GraphQl within the register functions, so let's add it back in.
1 register(/* { strapi } */) {},
The complete code should look like this:
1 "use strict";
2 const boostrap = require("./bootstrap");
3
4 module.exports = {
5 async bootstrap() {
6 await boostrap();
7 },
8
9 register(/* { strapi } */) {},
10 };
Let's use GraphQL's extension service to allow us to add our custom resolvers by adding the following to our index.js
file.
1 "use strict";
2 const boostrap = require("./bootstrap");
3
4 module.exports = {
5 async bootstrap() {
6 await boostrap();
7 },
8
9 register({ strapi }) {
10 const extensionService = strapi.service("plugin::graphql.extension");
11 extensionService.use(// add extension code here);
12 },
13 };
The schema generated by the Content API can be extended by registering an extension. This extension, defined either as an object or a function returning an object, will be used by the use()
function exposed by the extension service
provided with the GraphQL plugin. You can read more here.
The object describing the extension accepts the following parameters:
Parameter | Type | Description |
---|---|---|
types | Array | Allows extending the schema types using Nexus-based type definitions |
typeDefs | String | Allows extending the schema types using GraphQL SDL |
plugins | Array | Allows extending the schema using Nexus plugins |
resolvers | Object | Defines custom resolvers |
resolversConfig | Object | Defines configuration options for the resolvers, such as authorization, policies and middlewares |
You can extend the types using Nexusor do it via typeDefs using GraphQL SDL; this is the approach we are going to take here since we can write a whole article on using Nexus.
Before filling out the logic, let's pass the following function into the use()
method.
1 ({ strapi }) => ({
2 typeDefs: ``,
3 resolvers: {},
4 });
Our completed code should look like this:
1 "use strict";
2
3 const boostrap = require("./bootstrap");
4
5 module.exports = {
6 async bootstrap() {
7 await boostrap();
8 },
9
10 register({ strapi }) {
11 const extensionService = strapi.service("plugin::graphql.extension");
12
13 extensionService.use(({ strapi }) => ({
14 typeDefs: ``,
15 resolvers: {},
16 }));
17 },
18 };
We are passing strapi
so we can access its methods.
Now that you have a base schema let's add a custom query. Queries
A GraphQL query is used to read or fetch values, while a mutation is used to write or post values. In either case, the operation is a simple string that a GraphQL server can parse and respond to with data in a specific format. – Tutorialpoints
For this example, we will overide our article
query to allow us to to use a slug instead of an id to query our data.
Currently, our query definition looks like this:
1 article(id: ID): ArticleEntityResponse
It takes an id and returns our ArticleEntityResponse, which was automatically generated for us when we created the article content type. Let's override it to take a slug vs id. In our code, add this snippet:
1 typeDefs: `
2 type Query {
3 article(slug: String!): ArticleEntityResponse
4 }
5 `,
This query specifies the query name the parameters will take; in this case:
article
is the name of our query we are overriding.slug
is the parameter of the type string that is required to be passed in our query.ArticleEntityResponse
is the data that we are returning.Our completed code should look like this:
1 "use strict";
2
3 const boostrap = require("./bootstrap");
4
5 module.exports = {
6 async bootstrap() {
7 await boostrap();
8 },
9
10 register({ strapi }) {
11 const extensionService = strapi.service("plugin::graphql.extension");
12
13 extensionService.use(({ strapi }) => ({
14 typeDefs: `
15 type Query {
16 article(slug: String!): ArticleEntityResponse
17 }
18 `,
19 resolvers: {},
20 }));
21 },
22 };
Now in our GraphQl playground we should be able to pass a slug instead of an id in our article query:
However, if you attempt to run your query right now, it will not work. This makes perfect sense since you’ve only specified the new query type you want to override, but not how to resolve that query and return data. This is where resolvers come into play.
We now have to override our resolver to expect a slug as a parameter and write custom logic to allow us to return the correct data. Let's create our resolver and then review the code and what it does. When defining resolvers, you have two options. You can override an existing controller or create a fully custom one. In this case, we will override our article
resolver.
Add the following code into your custom schema.
1 resolvers: {
2 Query: {
3 article: {
4 resolve: async (parent, args, context) => {
5
6 const { toEntityResponse } = strapi.service(
7 "plugin::graphql.format"
8 ).returnTypes;
9
10 const data = await strapi.services["api::article.article"].find({
11 filters: { slug: args.slug },
12 });
13
14 const response = toEntityResponse(data.results[0]);
15
16 console.log("##################", response, "##################");
17
18 return response;
19 },
20 },
21 },
22 },
Our completed code should look like this:
1 "use strict";
2 const boostrap = require("./bootstrap");
3
4 module.exports = {
5 async bootstrap() {
6 await boostrap();
7 },
8
9 register({ strapi }) {
10 const extensionService = strapi.service("plugin::graphql.extension");
11 extensionService.use(({ strapi }) => ({
12 typeDefs: `
13 type Query {
14 article(slug: String!): ArticleEntityResponse
15 }
16 `,
17 resolvers: {
18 Query: {
19 article: {
20 resolve: async (parent, args, context) => {
21 const { toEntityResponse } = strapi.service(
22 "plugin::graphql.format"
23 ).returnTypes;
24
25 const data = await strapi.services["api::article.article"].find({
26 filters: { slug: args.slug },
27 });
28
29 const response = toEntityResponse(data.results[0]);
30
31 console.log("##################", response, "##################");
32
33 return response;
34 },
35 },
36 },
37 },
38 }));
39 },
40 };
Once you have saved the changes to your schema, restart the server and run yarn develop
again to make sure the changes are reflected, and run the following query below.
1 query {
2 article(slug: "what-s-inside-a-black-hole") {
3 data {
4 id
5 attributes {
6 title
7 description
8 content
9 slug
10 }
11 }
12 }
13 }
Success! We extended a resolver and now your query returning data based on the slug.
Let's quickly review what each piece of our code does. We get the toEntityResponse
method to allow us to convert our response to the appropriate format before returning the data.
1 const { toEntityResponse } = strapi.service(
2 "plugin::graphql.format"
3 ).returnTypes;
Instead of our resolvers being tied to controllers like they were in Strapi v3, in v4, we call our services directly. In this case, we are calling a service that was auto-generated for us when we created our article
content type, but we can create custom services if we choose.
1 const data = await strapi.services["api::article.article"].find({
2 filters: { slug: args.slug },
3 });
Finally, we call our toEntityResponse
to convert our response to the appropriate format before returning the data.
1 const response = toEntityResponse(data.results[0]);
2 return response;
We just took a look at how to override an existing resolver. Let's now look at how we can create a custom GraphQL query resolver from scratch.
We will follow simmilar steps as before. Let's create a placeholder schema object that will include the following:
Paste the following in your code:
1 // Going to be our custom query resolver to get all authors and their details.
2 extensionService.use(({ strapi }) => ({
3 typeDefs: ``,
4 resolvers: {},
5 resolversConfig: {},
6 }));
Our completed code should look like this:
1 "use strict";
2
3 const boostrap = require("./bootstrap");
4
5 module.exports = {
6 async bootstrap() {
7 await boostrap();
8 },
9
10 register({ strapi }) {
11 const extensionService = strapi.service("plugin::graphql.extension");
12
13 // Previous code from before
14 extensionService.use(({ strapi }) => ({}));
15
16 // Going to be our custom query resolver to get all authors and their details.
17 extensionService.use(({ strapi }) => ({
18 typeDefs: ``,
19 resolvers: {},
20 resolversConfig: {},
21 }));
22 },
23 };
Let's define our query and type definitions.
1 typeDefs: `
2 type Query {
3 authorsContacts: [AuthorContact]
4 }
5
6 type AuthorContact {
7 id: ID
8 name: String
9 email: String
10 articles: [Article]
11 }
12 `,
Let's define our resolver.
1 resolvers: {
2 Query: {
3 authorsContacts: {
4 resolve: async (parent, args, context) => {
5
6 const data = await strapi.services["api::writer.writer"].find({
7 populate: ["articles"],
8 });
9
10 return data.results.map(author => ({
11 id: author.id,
12 name: author.name,
13 email: author.email,
14 articles: author.articles,
15 }));
16
17 }
18 }
19 },
20 },
Let's define configurations to allow us public access when making the request.
1 resolversConfig: {
2 "Query.authorsContacts": {
3 auth: false,
4 },
5 },
Our completed code should look like this:
1 "use strict";
2
3 const boostrap = require("./bootstrap");
4
5 module.exports = {
6 async bootstrap() {
7 await boostrap();
8 },
9
10 register({ strapi }) {
11 const extensionService = strapi.service("plugin::graphql.extension");
12
13 // Previous code from before
14 extensionService.use(({ strapi }) => ({}));
15
16 // Code we just added - custom graphql resolver
17 extensionService.use(({ strapi }) => ({
18 typeDefs: `
19
20 type Query {
21 authorsContacts: [AuthorContact]
22 }
23
24 type AuthorContact {
25 id: ID
26 name: String
27 email: String
28 articles: [Article]
29 }
30 `,
31
32 resolvers: {
33 Query: {
34 authorsContacts: {
35 resolve: async (parent, args, context) => {
36 const data = await strapi.services["api::writer.writer"].find({
37 populate: ["articles"],
38 });
39
40 return data.results.map((author) => ({
41 id: author.id,
42 name: author.name,
43 email: author.email,
44 articles: author.articles,
45 }));
46 },
47 },
48 },
49 },
50
51 resolversConfig: {
52 "Query.authorsContacts": {
53 auth: false,
54 },
55 },
56 }));
57 },
58 };
Let's quickly review what each piece of our code in our custom resolver does.
We get the services
to fetch our writer data from the database. Then, we pass our populate flag to allow us to populate the article relation data.
1 const data = await strapi.services["api::writer.writer"].find({
2 populate: ["articles"],
3 });
Before returning our data, we transform our response to match our AuthorContact
types definition to be returned in our GraphQl response.
1 return data.results.map((author) => ({
2 id: author.id,
3 name: author.name,
4 email: author.email,
5 articles: author.articles,
6 }));
We just took a look at a basic way to create a custom GraphQl resolver in Strapi v4. Once you have saved the changes to your schema, restart the server and run yarn develop
again to make sure the changes are reflected, and run the following query below.
1 query {
2 authorsContacts {
3 id
4 name
5 email
6 articles {
7 title
8 description
9 publishedAt
10 }
11 }
12 }
You should now see the results from our custom query.
You can verify our newly created query by looking at the GraphQL Playground schema:
When looking at this code, everything may seem like it is working correctly, but there is an issue here, and it has something to do with passing populate
to our find()
method.
1 const data = await strapi.services["api::writer.writer"].find({
2 populate: ["articles"],
3 });
Whenever we pass populate,
we will always make an additional call to fetch the articles data from the database even if we don't explicitly ask for it in our query. What we need to do, is to create a resolver chain to query the articles separately.
First, let's refactor our previous code by removing the articles reference in AuthorContact:
1 type AuthorContact {
2 id: ID
3 name: String
4 email: String
5 articles: [Article] <-- REMOVE THIS
6 }
Now let's remove the populate argument that we are passing here:
1 resolvers: {
2 Query: {
3 authorsContacts: {
4 resolve: async (parent, args, context) => {
5 const data = await strapi.services["api::writer.writer"].find({
6 populate: ["articles"], <-- REMOVE THIS
7 });
8
9 return data.results.map((author) => ({
10 id: author.id,
11 name: author.name,
12 email: author.email,
13 articles: author.articles, <-- REMOVE THIS
14 }));
15 },
16 },
17 },
18 },
Now your code should look like this:
1 extensionService.use(({ strapi }) => ({
2 typeDefs: `
3
4 type Query {
5 authorsContacts: [AuthorContact]
6 }
7
8 type AuthorContact {
9 id: ID
10 name: String
11 email: String
12 }
13
14 `,
15
16 resolvers: {
17 Query: {
18 authorsContacts: {
19 resolve: async (parent, args, context) => {
20 const data = await strapi.services["api::writer.writer"].find();
21
22 return data.results.map((author) => ({
23 id: author.id,
24 name: author.name,
25 email: author.email,
26 }));
27 },
28 },
29 },
30 },
31
32 resolversConfig: {
33 "Query.authorsContacts": {
34 auth: false,
35 },
36 },
37 }));
Now, let's do things the right way and create a child resolver to fetch articles associated with the author instead. This way, if we don't ask for the 'articles' in the query, we won't be fetching the data like in our previous example.
Let's define AuthorsArticles type and make sure to add it to AuthorContact type:
1 type AuthorsArticles {
2 id: ID
3 title: String
4 slug: String
5 description: String
6 }
7
8 type AuthorContact {
9 id: ID
10 name: String
11 email: String
12 articles: [AuthorsArticles]
13 }
Now let's create our child resolver to fetch all articles associated with the author:
1 AuthorContact: {
2 articles: {
3 resolve: async (parent, args, context) => {
4
5 console.log("#############", parent.id, "#############");
6
7 const data = await strapi.services["api::article.article"].find({
8 filters: { author: parent.id },
9 });
10
11 return data.results.map((article) => ({
12 id: article.id,
13 title: article.title,
14 slug: article.slug,
15 description: article.description,
16 }));
17
18 },
19 },
20 },
Our completed code should look like this:
1 "use strict";
2 const boostrap = require("./bootstrap");
3
4 module.exports = {
5 async bootstrap() {
6 await boostrap();
7 },
8
9 register({ strapi }) {
10 const extensionService = strapi.service("plugin::graphql.extension");
11
12 // Overriding the default article GraphQL resolver
13 extensionService.use(({ strapi }) => ({
14 typeDefs: `
15 type Query {
16 article(slug: String!): ArticleEntityResponse
17 }
18 `,
19 resolvers: {
20 Query: {
21 article: {
22 resolve: async (parent, args, context) => {
23 const { toEntityResponse } = strapi.service(
24 "plugin::graphql.format"
25 ).returnTypes;
26
27 const data = await strapi.services["api::article.article"].find({
28 filters: { slug: args.slug },
29 });
30
31 const response = toEntityResponse(data.results[0]);
32
33 console.log("##################", response, "##################");
34
35 return response;
36 },
37 },
38 },
39 },
40 }));
41
42 // Custom query resolver to get all authors and their details.
43 extensionService.use(({ strapi }) => ({
44 typeDefs: `
45
46 type Query {
47 authorsContacts: [AuthorContact]
48 }
49
50 type AuthorsArticles {
51 id: ID
52 title: String
53 slug: String
54 description: String
55 }
56
57 type AuthorContact {
58 id: ID
59 name: String
60 email: String
61 articles: [AuthorsArticles]
62 }
63
64 `,
65
66 resolvers: {
67 Query: {
68 authorsContacts: {
69 resolve: async (parent, args, context) => {
70 const data = await strapi.services["api::writer.writer"].find();
71
72 return data.results.map((author) => ({
73 id: author.id,
74 name: author.name,
75 email: author.email,
76 }));
77 },
78 },
79 },
80
81 AuthorContact: {
82 articles: {
83 resolve: async (parent, args, context) => {
84
85 console.log("#############", parent.id, "#############");
86
87 const data = await strapi.services["api::article.article"].find({
88 filters: { author: parent.id },
89 });
90
91 return data.results.map((article) => ({
92 id: article.id,
93 title: article.title,
94 slug: article.slug,
95 description: article.description,
96 }));
97 },
98 },
99 },
100 },
101
102 resolversConfig: {
103 "Query.authorsContacts": {
104 auth: false,
105 },
106 },
107 }));
108 },
109 };
We now have a separate resolver to fetch articles
that are associated with the author. Go ahead and run this query:
1 query {
2 authorsContacts {
3 id
4 name
5 email
6 articles {
7 id
8 title
9 description
10 slug
11 }
12 }
13 }
To sum up, when working with GraphQL, you should create a resolver for each related item you want to populate. Final Code on GitHub Hope you enjoyed this introduction to the the basics of extending and creating custom resolvers with GralhQL in Strapi v4.
As you can see, Strapi provides a highly flexible environment that can be used to create a fully functional content API in minutes. Plus, Strapi allows for full control over the API and system. Whether you’re looking to create a simple headless content system for your blog or to fully centralize your e-commerce product information, Strapi offers a robust backend.
I hope that you found this tutorial helpful. If you have any additional questions, join us at our Discord community, where you can ask questions or help other members with theirs.