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.
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.
Solution 1: disable all mutations for public-facing types (recommended)
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')],
},
},
});
},
};
Complete recommended setup for a typical CMS frontend
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
| Pitfall | Problem | Fix |
|---|---|---|
| Mutations exposed by default | Anyone can create/update/delete content | disableMutations() on all read-only types |
| Introspection enabled in production | Schema leaks to attackers | Set introspection: false in production config |
| No depth limit | Malicious nested queries crash the server | Set depthLimit: 5-7 |
| Trusting REST checks alone for GraphQL | GraphQL has its own schema/resolver config | Configure resolversConfig and shadowCRUD separately |
| N+1 queries on relations | Slow list queries | Use DataLoader or ensure population is optimized |
Missing auth: false on public queries | 403 for anonymous users | Set auth: false in resolversConfig |
| Sandbox enabled in production | Security and information disclosure risk | Set landingPage: false |
| Sensitive user fields exposed | Email, password hash visible in schema | field('email').disable() on user type |
See also
- Custom Controllers and Services - REST counterpart of GraphQL resolvers
- Relations and Population - population patterns that apply to GraphQL too
- Authentication and Permissions - auth context in resolvers
- Performance and Caching - query optimization strategies