Skip to main content

GraphQL Customization

Strapi auto-generates a full GraphQL schema from your content types. This page covers how to extend that schema, add custom resolvers, apply middleware, secure queries, and avoid performance traps.

Mutations are exposed by default

When you install @strapi/plugin-graphql, Strapi generates full CRUD mutations (create, update, delete) for every content type. Authorization still goes through API tokens or the Users & Permissions plugin, but the mutation fields exist in the schema unless you explicitly lock them down.

Any client with a token or role that can reach those mutations can create, update, or delete content if you don't take action. See Securing the default schema below.

Enabling GraphQL​

npm install @strapi/plugin-graphql
# or
yarn add @strapi/plugin-graphql
// config/plugins.js
module.exports = {
graphql: {
config: {
endpoint: '/graphql',
shadowCRUD: true, // Auto-generate types from content types
landingPage: false, // Disable Apollo Sandbox everywhere
depthLimit: 7, // Prevent deeply nested queries
defaultLimit: 25, // Page size when no pagination is passed
maxLimit: 100, // Cap client-requested page sizes
v4CompatibilityMode: false, // Keep Strapi 5's flattened response format
apolloServer: {
tracing: false,
introspection: true, // Set to false in production
},
},
},
};

Auto-generated schema​

For a content type Article with fields title, content, author (relation), and tags (relation), Strapi generates:

type Article {
documentId: ID!
title: String!
content: String
author: Author
tags: [Tag]
createdAt: DateTime
updatedAt: DateTime
publishedAt: DateTime
locale: String
}

type ArticleConnection {
nodes: [Article!]!
pageInfo: Pagination!
}

type Query {
article(
documentId: ID!
locale: I18NLocaleCode
status: PublicationStatus
): Article
articles(
filters: ArticleFiltersInput
pagination: PaginationArg
sort: [String]
locale: I18NLocaleCode
status: PublicationStatus
publicationFilter: PublicationFilter
): [Article!]!
articles_connection(
filters: ArticleFiltersInput
pagination: PaginationArg
sort: [String]
locale: I18NLocaleCode
status: PublicationStatus
publicationFilter: PublicationFilter
): ArticleConnection!
}

type Mutation {
createArticle(data: ArticleInput!): Article
updateArticle(documentId: ID!, data: ArticleInput!): Article
deleteArticle(documentId: ID!): Article
}

Securing the default schema (critical)​

This is the most important section on this page. By default, the auto-generated schema exposes mutation fields that can create, update, and delete your content when a role or token is allowed to call them.

The problem​

With the GraphQL plugin installed, these mutation fields exist in the schema:

# Anyone can delete your articles if mutations are not locked down
mutation {
deleteArticle(documentId: "abc123") {
documentId
title
}
}

# Or create spam content
mutation {
createArticle(data: { title: "HACKED", content: "spam" }) {
documentId
}
}

With the Users & Permissions plugin, Strapi checks the matching permission (find, findOne, create, update, or delete) for GraphQL too. However, leaving write operations in a public schema makes mistakes with API tokens, role permissions, or auth: false resolver configuration much more dangerous. Disable mutations that a frontend should never call.

The cleanest approach - remove mutations from the schema entirely for content types that should be read-only:

// src/index.js
module.exports = {
register({ strapi }) {
const extensionService = strapi.plugin('graphql').service('extension');

// Articles: read-only (no create, update, delete via GraphQL)
extensionService.shadowCRUD('api::article.article').disableMutations();

// Pages: read-only
extensionService.shadowCRUD('api::page.page').disableMutations();

// Authors: read-only
extensionService.shadowCRUD('api::author.author').disableMutations();

// Tags: read-only
extensionService.shadowCRUD('api::tag.tag').disableMutations();

// Categories: read-only
extensionService.shadowCRUD('api::category.category').disableMutations();
},
};

After this, the mutations simply don't exist in the schema. An attacker can't call what doesn't exist.

Solution 2: disable entire types that should not be exposed​

Some content types should never be accessible via GraphQL at all (internal config, logs, etc.):

// src/index.js
module.exports = {
register({ strapi }) {
const extensionService = strapi.plugin('graphql').service('extension');

// Completely remove from the GraphQL schema (no queries, no mutations)
extensionService.shadowCRUD('api::audit-log.audit-log').disable();
extensionService.shadowCRUD('api::site-settings.site-settings').disable();

// Hide sensitive user fields even if the type is exposed
extensionService.shadowCRUD('plugin::users-permissions.user').field('email').disable();
extensionService.shadowCRUD('plugin::users-permissions.user').field('password').disable();
extensionService.shadowCRUD('plugin::users-permissions.user').field('resetPasswordToken').disable();
extensionService.shadowCRUD('plugin::users-permissions.user').field('confirmationToken').disable();
},
};

Solution 3: disable individual mutations​

If you need create but not delete, disable specific actions:

module.exports = {
register({ strapi }) {
const extensionService = strapi.plugin('graphql').service('extension');

// Allow creating reviews, but not updating or deleting them
extensionService.shadowCRUD('api::review.review').disableAction('update');
extensionService.shadowCRUD('api::review.review').disableAction('delete');

// Disable only queries (e.g., write-only contact form)
extensionService.shadowCRUD('api::contact.contact').disableAction('find');
extensionService.shadowCRUD('api::contact.contact').disableAction('findOne');
},
};

Solution 4: require authentication on mutations via resolversConfig​

If you want the mutations to exist in the schema (for authenticated admin use) but block unauthenticated access:

module.exports = {
register({ strapi }) {
const extensionService = strapi.plugin('graphql').service('extension');

extensionService.use({
resolversConfig: {
// Public read access (no auth required)
'Query.articles': { auth: false },
'Query.article': { auth: false },

// Mutations require authentication + specific permissions
'Mutation.createArticle': {
auth: {
scope: ['api::article.article.create'],
},
},
'Mutation.updateArticle': {
auth: {
scope: ['api::article.article.update'],
},
},
'Mutation.deleteArticle': {
auth: {
scope: ['api::article.article.delete'],
},
},
},
});
},
};

Solution 5: role-based mutation guard middleware​

For fine-grained control, use a middleware that checks the user's role:

module.exports = {
register({ strapi }) {
const extensionService = strapi.plugin('graphql').service('extension');

// Reusable middleware: only allow specific roles
const requireRole = (...allowedRoles) => {
return async (next, parent, args, context, info) => {
const user = context.state?.user;

if (!user) {
throw new Error('Authentication required');
}

if (!allowedRoles.includes(user.role?.type)) {
throw new Error(
`Forbidden: requires one of [${allowedRoles.join(', ')}], ` +
`but you are "${user.role?.type}"`
);
}

return next(parent, args, context, info);
};
};

extensionService.use({
resolversConfig: {
// Anyone can read
'Query.articles': { auth: false },
'Query.article': { auth: false },

// Only editors and admins can create
'Mutation.createArticle': {
middlewares: [requireRole('editor', 'admin')],
},

// Only admins can update and delete
'Mutation.updateArticle': {
middlewares: [requireRole('admin')],
},
'Mutation.deleteArticle': {
middlewares: [requireRole('admin')],
},
},
});
},
};

Most Strapi projects use GraphQL as a read-only API for the frontend, while content is managed via the admin panel. Here is the recommended secure configuration:

// src/index.js
module.exports = {
register({ strapi }) {
const extensionService = strapi.plugin('graphql').service('extension');

// ── 1. Remove mutations from all public-facing content types ──
const readOnlyTypes = [
'api::article.article',
'api::page.page',
'api::author.author',
'api::tag.tag',
'api::category.category',
];

for (const uid of readOnlyTypes) {
extensionService.shadowCRUD(uid).disableMutations();
}

// ── 2. Completely hide internal types ──
const hiddenTypes = [
'api::audit-log.audit-log',
'api::failed-webhook.failed-webhook',
];

for (const uid of hiddenTypes) {
try {
extensionService.shadowCRUD(uid).disable();
} catch {
// Type might not exist, that's fine
}
}

// ── 3. Hide sensitive user fields ──
const sensitiveUserFields = [
'email', 'password', 'resetPasswordToken',
'confirmationToken', 'provider',
];

for (const field of sensitiveUserFields) {
try {
extensionService.shadowCRUD('plugin::users-permissions.user').field(field).disable();
} catch {
// Field might not exist
}
}

// ── 4. Make read queries public, require auth for everything else ──
extensionService.use({
resolversConfig: {
'Query.articles': { auth: false },
'Query.article': { auth: false },
'Query.pages': { auth: false },
'Query.page': { auth: false },
'Query.authors': { auth: false },
'Query.author': { auth: false },
'Query.tags': { auth: false },
'Query.categories': { auth: false },
},
});
},
};

Verifying your lockdown​

After applying the configuration, test it:

# This should work (read):
query { articles { documentId title } }

# This should fail with "Cannot query field" (mutation removed from schema):
mutation { deleteArticle(documentId: "abc") { documentId } }

# This should fail (hidden type):
query { auditLogs { documentId } }

# This should NOT return email (hidden field):
query { usersPermissionsUsers { username email } }

If any of these succeed when they shouldn't, review your register() configuration.


Extending the schema with custom resolvers​

Use the extension service in src/index.js:

Adding a custom query​

// src/index.js
module.exports = {
register({ strapi }) {
const extensionService = strapi.plugin('graphql').service('extension');

extensionService.use(({ strapi }) => ({
typeDefs: `
type Query {
featuredArticles: [Article]!
articleBySlug(slug: String!, locale: I18NLocaleCode): Article
}
`,

resolvers: {
Query: {
featuredArticles: {
resolve: async (parent, args, context) => {
const articles = await strapi.documents('api::article.article').findMany({
filters: { featured: true },
status: 'published',
populate: ['author', 'cover', 'tags'],
sort: { publishedAt: 'desc' },
limit: 10,
});
return articles;
},
},

articleBySlug: {
resolve: async (parent, { slug, locale }, context) => {
const articles = await strapi.documents('api::article.article').findMany({
filters: { slug: { $eq: slug } },
locale: locale || 'en',
status: 'published',
populate: ['author', 'cover', 'tags', 'seo'],
limit: 1,
});
return articles[0] || null;
},
},
},
},
}));
},
};

Adding a custom mutation​

extensionService.use(({ strapi }) => ({
typeDefs: `
input ContactFormInput {
name: String!
email: String!
message: String!
subject: String
}

type ContactFormResponse {
success: Boolean!
message: String!
}

type Mutation {
submitContactForm(input: ContactFormInput!): ContactFormResponse!
}
`,

resolvers: {
Mutation: {
submitContactForm: {
resolve: async (parent, { input }, context) => {
const { name, email, message, subject } = input;

// Validate
if (!email.includes('@')) {
return { success: false, message: 'Invalid email address' };
}

// Store the submission
await strapi.documents('api::contact.contact').create({
data: { name, email, message, subject },
});

// Send notification email
await strapi.plugins['email'].services.email.send({
to: 'admin@example.com',
subject: `Contact: ${subject || 'No subject'}`,
html: `<p><strong>${name}</strong> (${email}):</p><p>${message}</p>`,
});

return { success: true, message: 'Message sent successfully' };
},
},
},
},
}));

Custom field resolvers​

Add computed fields to existing types:

extensionService.use(({ strapi }) => ({
typeDefs: `
type Article {
readingTime: Int
excerpt: String
}
`,

resolvers: {
Article: {
readingTime: {
resolve: (parent) => {
const content = parent.content || '';
const words = content.split(/\s+/).filter(Boolean).length;
return Math.ceil(words / 200);
},
},

excerpt: {
resolve: (parent) => {
const content = parent.content || '';
const plainText = content.replace(/<[^>]*>/g, ''); // Strip HTML
return plainText.length > 200
? plainText.substring(0, 200) + '...'
: plainText;
},
},
},
},
}));

GraphQL middleware​

Apply middleware to specific resolvers for logging, caching, or authorization:

extensionService.use({
resolversConfig: {
// Apply to a specific query
'Query.articles': {
middlewares: [
// Logging middleware
async (next, parent, args, context, info) => {
console.time('articles-query');
const result = await next(parent, args, context, info);
console.timeEnd('articles-query');
return result;
},

// Cache-control middleware
async (next, parent, args, context, info) => {
info.cacheControl.setCacheHint({ maxAge: 300, scope: 'PUBLIC' });
return next(parent, args, context, info);
},
],
},

// Disable auth for public queries
'Query.articleBySlug': {
auth: false,
},

// Restrict mutations to authenticated users
'Mutation.createArticle': {
auth: {
scope: ['api::article.article.create'],
},
},

// Custom auth check
'Mutation.deleteArticle': {
middlewares: [
async (next, parent, args, context, info) => {
const user = context.state?.user;
if (!user || user.role?.type !== 'admin') {
throw new Error('Only admins can delete articles');
}
return next(parent, args, context, info);
},
],
},
},
});

Production security​

Disable introspection​

// config/env/production/plugins.js
module.exports = {
graphql: {
config: {
landingPage: false,
apolloServer: {
introspection: false, // Prevents schema discovery
},
},
},
};

Query depth limiting​

module.exports = {
graphql: {
config: {
depthLimit: 5, // Prevent { article { author { articles { author { ... } } } } }
defaultLimit: 25, // Default page size
maxLimit: 50, // Max client-requested page size
},
},
};

Rate limiting on the GraphQL endpoint​

Use a global middleware:

// src/middlewares/graphql-rate-limit.js
const rateLimit = new Map();

module.exports = (config, { strapi }) => {
const { maxRequests = 100, windowMs = 60000 } = config;

return async (ctx, next) => {
if (ctx.url !== '/graphql') {
return next();
}

const ip = ctx.ip;
const now = Date.now();
const windowStart = now - windowMs;

const requests = rateLimit.get(ip) || [];
const recentRequests = requests.filter(t => t > windowStart);

if (recentRequests.length >= maxRequests) {
ctx.status = 429;
ctx.body = { errors: [{ message: 'Too many requests' }] };
return;
}

recentRequests.push(now);
rateLimit.set(ip, recentRequests);

await next();
};
};

Solving N+1 with DataLoader​

The auto-generated schema can cause N+1 queries when resolving relations. For custom resolvers, use DataLoader:

const DataLoader = require('dataloader');

// Create a loader per request
function createAuthorLoader(strapi) {
return new DataLoader(async (authorIds) => {
const authors = await strapi.documents('api::author.author').findMany({
filters: { documentId: { $in: authorIds } },
});

// Return in the same order as the input IDs
const authorMap = new Map(authors.map(a => [a.documentId, a]));
return authorIds.map(id => authorMap.get(id) || null);
});
}

// Use in resolver
extensionService.use(({ strapi }) => ({
resolvers: {
Article: {
author: {
resolve: async (parent, args, context) => {
if (!context.loaders) {
context.loaders = {};
}
if (!context.loaders.author) {
context.loaders.author = createAuthorLoader(strapi);
}
return context.loaders.author.load(parent.author?.documentId);
},
},
},
},
}));

Example queries​

Filtered list with pagination​

query Articles($locale: I18NLocaleCode, $page: Int, $pageSize: Int) {
articles_connection(
locale: $locale
pagination: { page: $page, pageSize: $pageSize }
sort: "publishedAt:desc"
filters: { tags: { slug: { eq: "javascript" } } }
status: PUBLISHED
) {
nodes {
documentId
title
slug
publishedAt
author {
name
}
cover {
url
alternativeText
}
}
pageInfo {
page
pageSize
pageCount
total
}
}
}

Single entry with deep population​

query ArticleBySlug($slug: String!) {
articleBySlug(slug: $slug) {
documentId
title
content
publishedAt
readingTime
author {
name
bio
avatar { url }
}
tags { name slug }
seo {
metaTitle
metaDescription
ogImage { url width height }
}
}
}

Common pitfalls​

PitfallProblemFix
Mutations exposed by defaultAnyone can create/update/delete contentdisableMutations() on all read-only types
Introspection enabled in productionSchema leaks to attackersSet introspection: false in production config
No depth limitMalicious nested queries crash the serverSet depthLimit: 5-7
Trusting REST checks alone for GraphQLGraphQL has its own schema/resolver configConfigure resolversConfig and shadowCRUD separately
N+1 queries on relationsSlow list queriesUse DataLoader or ensure population is optimized
Missing auth: false on public queries403 for anonymous usersSet auth: false in resolversConfig
Sandbox enabled in productionSecurity and information disclosure riskSet landingPage: false
Sensitive user fields exposedEmail, password hash visible in schemafield('email').disable() on user type

See also​