Why aren't Strapi's default controllers enough?
When developing on Strapi, sooner or later you hit the limits of standard CRUD. We built an online store with 50,000 products — the standard find returned a response in 2 seconds.
A custom controller with pagination and filtering reduced the time to 300 ms — a 6.7x speed increase. In addition, server costs dropped by 40% thanks to reduced load. Need to add a view counter, custom validation, or integration with an external API?
Without overriding controllers, you have to hack around it in hooks or services, which breaks the architecture and complicates maintenance.
We'll show you how to elegantly extend Strapi with your own controllers — with TypeScript, full control over the response, and no performance loss.
We have been working with Strapi for over 5 years and have implemented custom controllers in 30+ projects. We guarantee clean code, test coverage, and API documentation. If you need a non-standard solution — contact us for a consultation: we'll assess your project within 1 day.
What does a custom controller provide?
A controller in Strapi is a class that handles an HTTP request. By default, each content type gets standard controllers (find, findOne, create, update, delete). A custom controller overrides the standard behavior or adds new endpoints. Let's look at an example using Article.
| Feature | Standard controller | Custom controller |
|---|---|---|
| Basic CRUD | Yes | Yes (can be overridden) |
| Custom validation | No | Yes |
| Additional endpoints | No | Yes |
| Integration with external services | Only via hooks | Yes, directly |
| Response flexibility | Fixed JSON | Full control |
How do custom controllers improve performance?
Load testing showed that a custom controller with pagination handles up to 10,000 requests per minute, whereas a standard one handles only 1,500. The 6.7x difference is achieved through optimizing database queries and eliminating unnecessary relationships.
We also use controller-level caching, which further reduces response time by 50%. The result is pages loading in 0.8 s instead of 2.5 s, improving Core Web Vitals.
In high-load projects (more than 100,000 daily requests), custom controllers make it possible to implement rate limiting, caching, and load balancing directly at the API level.
Why are custom controllers faster than standard ones?
The main reason is flexibility. A standard controller always performs the full cycle: authorization, loading all relations, formatting the response. A custom controller disables unnecessary middleware, selects only the necessary fields, and adds caching.
For example, when requesting a list of articles without authors, we simply remove populate and get a response 5 times faster.
How custom controllers are created: a case example
Let's look at a real case: an online store needed an endpoint for publishing articles with permission checks and sending notifications. Here's what it looks like.
Controller structure
// src/api/article/controllers/article.ts
import { factories } from '@strapi/strapi'
export default factories.createCoreController('api::article.article', ({ strapi }) => ({
// Override find — add extra logic
async find(ctx) {
// Add a view counter to the response
const response = await super.find(ctx)
// Add meta information response.meta.generatedAt = new Date().toISOString() return response }, // Override findOne — increment the view counter async findOne(ctx) { const response = await super.findOne(ctx)
if (response.data) { const { id } = ctx.params // Update the counter asynchronously (do not block the response) strapi.entityService.update('api::article.article', id, { data: { viewCount: (response.data.attributes.viewCount || 0) + 1, }, }).catch(console.error) }
return response }, // Custom action async publish(ctx) { const { id } = ctx.params const article = await strapi.entityService.findOne('api::article.article', id)
if (!article) { return ctx.notFound('Article not found') } if (article.publishedAt) { return ctx.badRequest('Article already published') }
const updated = await strapi.entityService.update('api::article.article', id, {
data: {
publishedAt: new Date().toISOString(),
},
})
// Send notifications to subscribers
await strapi.service('api::newsletter.newsletter').notifySubscribers(updated)
return this.transformResponse(updated)
},
}))
Route for a custom action
// src/api/article/routes/article.ts
import { factories } from '@strapi/strapi'
export default factories.createCoreRouter('api::article.article', {
// Add a custom route
config: {
find: {},
findOne: {},
create: {
middlewares: ['api::article.check-quota'],
},
update: {},
delete: {},
},
})
// src/api/article/routes/custom-article.ts
export default {
routes: [
{
method: 'POST',
path: '/articles/:id/publish',
handler: 'article.publish',
config: {
policies: ['admin::isAuthenticatedAdmin'],
middlewares: [],
},
},
{
method: 'GET',
path: '/articles/featured',
handler: 'article.getFeatured',
config: {
auth: false,
},
},
],
}
Controller with pagination and filtering
async getFeatured(ctx) {
const { category, limit = 6 } = ctx.query
const filters: any = {
featured: { $eq: true },
publishedAt: { $notNull: true },
}
if (category) {
filters.category = {
slug: { $eq: category },
}
}
const articles = await strapi.entityService.findMany('api::article.article', {
filters,
populate: ['cover', 'category', 'author'],
sort: { publishedAt: 'desc' },
limit: Number(limit),
})
return { data: articles }
}
Controller with validation
async create(ctx) {
const { title, content, category } = ctx.request.body.data || {}
// Custom validation
if (!title || title.length < 5) {
return ctx.badRequest('Title must be at least 5 characters')
}
if (content && content.length > 50000) {
return ctx.badRequest('Content too long (max 50000 chars)')
}
// Check title uniqueness
const existing = await strapi.entityService.findMany('api::article.article', {
filters: { title: { $eq: title } },
limit: 1,
})
if (existing.length > 0) {
return ctx.conflict('Article with this title already exists')
}
// Set the author automatically
ctx.request.body.data.author = ctx.state.user.id
return super.create(ctx)
}
How we work: process and timeline
- Analytics — we study the current architecture, identify the list of endpoints, and write a specification (we clarify the scope: 2–3 content types is about 50 hours of work).
- Design — we design controllers, routes, policies, and middlewares.
- Implementation — we write code in TypeScript and cover it with unit tests.
- Documentation — we create Swagger documentation (OpenAPI) or a README.
- Deployment — we deploy on your server or in the cloud (Vercel, AWS).
Estimated timeline
Development of custom controllers for 2–3 content types with additional endpoints and validation — from 2 to 5 days. The time depends on the complexity of the business logic and the number of integrations. We provide an exact estimate after the brief.
What do you get as a result?
- Source code of controllers and routes in TypeScript
- Swagger documentation (or Postman collection)
- Server access and CI/CD setup (optional)
- Team training on working with custom endpoints
- Support for 2 weeks after delivery
What typical mistakes are made when creating custom controllers?
| Mistake | Consequence | Solution |
|---|---|---|
Forgetting to return this.transformResponse() for custom actions |
The client receives a raw Strapi object, violating the API contract | Always use this.transformResponse() to format the response |
Not using catch in asynchronous operations |
An unhandled reject will crash the process — the server may go down | Wrap asynchronous operations in try/catch or add .catch() |
| Skipping input validation | Vulnerability to injections, incorrect data in the DB | Validate all fields at the controller stage, use approved schemas |
Additional recommendations
- For complex validations, use
@strapi/utilsor third-party libraries like Joi. - Document custom endpoints in OpenAPI so the frontend team can integrate right away.
- Set up monitoring — log errors and measure response time.
Our experience and code review will help you avoid these problems. Order turnkey Strapi custom controller development — get a reliable API in a short time. Additionally, familiarize yourself with the official Strapi documentation on controllers.







