Skip to main content

Custom Controllers and Services

Controllers handle incoming requests and return responses. Services encapsulate reusable business logic. Strapi generates default CRUD controllers for every content type, but real projects almost always need to customise them.

How controllers and services relate

Rule of thumb: controllers parse and validate requests, services contain business logic. Keep controllers thin.


Extending a core controller

The createCoreController factory gives you the default CRUD actions. Override individual methods while keeping the rest:

// src/api/article/controllers/article.js
const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::article.article', ({ strapi }) => ({

// Wrap the default find -- add custom logic before/after
async find(ctx) {
// Force a default locale filter
ctx.query = { ...ctx.query, locale: ctx.query.locale || 'en' };

const { data, meta } = await super.find(ctx);

// Add a custom timestamp to the meta
meta.fetchedAt = new Date().toISOString();

return { data, meta };
},

// Override create with custom validation
async create(ctx) {
const { title } = ctx.request.body.data;

if (!title || title.length < 5) {
return ctx.badRequest('Title must be at least 5 characters');
}

// Delegate to the default create
const response = await super.create(ctx);
return response;
},
}));

TypeScript version

// src/api/article/controllers/article.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController('api::article.article', ({ strapi }) => ({
async find(ctx) {
ctx.query = { ...ctx.query, locale: ctx.query.locale || 'en' };
const { data, meta } = await super.find(ctx);
return { data, meta };
},
}));

Creating a custom action

Add an entirely new endpoint (not part of the default CRUD):

// src/api/article/controllers/article.js
const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::article.article', ({ strapi }) => ({

// Custom action: GET /api/articles/featured
async findFeatured(ctx) {
const articles = await strapi.documents('api::article.article').findMany({
filters: { featured: true },
status: 'published',
populate: ['cover', 'author'],
});

const sanitized = await this.sanitizeOutput(articles, ctx);
return this.transformResponse(sanitized);
},

// Custom action: POST /api/articles/:id/like
async like(ctx) {
const { id } = ctx.params;
const article = await strapi.documents('api::article.article').findOne(id);

if (!article) {
return ctx.notFound('Article not found');
}

const updated = await strapi.documents('api::article.article').update(id, {
data: { likes: (article.likes || 0) + 1 },
});

return this.transformResponse(updated);
},
}));

You also need a custom route for each new action -- see the Custom Routes page.


Custom services

Services are where heavy business logic belongs. Strapi auto-generates one per content type, but you can extend or create new ones.

Extending a core service

// src/api/article/services/article.js
const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService('api::article.article', ({ strapi }) => ({

// Override find to add a computed field
async find(...args) {
const { results, pagination } = await super.find(...args);

// Add reading time estimate
results.forEach(article => {
if (article.content) {
const words = article.content.split(/\s+/).length;
article.readingTime = Math.ceil(words / 200);
}
});

return { results, pagination };
},
}));

Creating a standalone service

// src/api/notification/services/notification.js
module.exports = ({ strapi }) => ({

async sendPushNotification({ userId, title, body }) {
const user = await strapi.documents('plugin::users-permissions.user').findOne(userId, {
populate: ['pushTokens'],
});

if (!user?.pushTokens?.length) {
strapi.log.warn(`No push tokens for user ${userId}`);
return;
}

for (const token of user.pushTokens) {
await fetch('https://push-api.example.com/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.value, title, body }),
});
}

strapi.log.info(`Push notification sent to user ${userId}`);
},

async notifyAdmins(message) {
const admins = await strapi.query('admin::user').findMany({
where: { isActive: true },
});

for (const admin of admins) {
await strapi.plugins['email'].services.email.send({
to: admin.email,
subject: 'Admin Notification',
html: `<p>${message}</p>`,
});
}
},
});

Using a service from a controller

// In any controller
async create(ctx) {
const response = await super.create(ctx);

// Call the notification service
await strapi.service('api::notification.notification').sendPushNotification({
userId: ctx.state.user.id,
title: 'Article Published',
body: `Your article "${response.data.attributes.title}" is live!`,
});

return response;
},

Sanitizing output

Always sanitize responses to prevent leaking private fields or bypassing access rules:

async find(ctx) {
// Validates that query params are allowed for the current user
await this.validateQuery(ctx);

// Removes query params the user shouldn't access
const sanitizedQuery = await this.sanitizeQuery(ctx);

const { results, pagination } = await strapi
.service('api::article.article')
.find(sanitizedQuery);

// Removes fields the user shouldn't see (e.g., private fields, createdBy)
const sanitizedResults = await this.sanitizeOutput(results, ctx);

return this.transformResponse(sanitizedResults, { pagination });
}

Common pitfalls

PitfallProblemFix
Business logic in controllerHard to reuse, test, or composeMove logic to a service
Missing sanitizeOutputLeaks private fields (emails, internal IDs)Always sanitize before returning
Forgetting super.create(ctx)Loses built-in validation and lifecycle hooksCall super when extending, not replacing
Not calling transformResponseAPI response format differs from standard Strapi outputUse this.transformResponse() for consistent structure
Hardcoded content-type UIDsBreaks on renameDefine UIDs as constants

See also