Strapi Custom Development: Choosing the Right Customization Option

Strapi comes with a solid set of default features. But sooner or later, you’ll have a requirement that cannot be covered by the defaults. This could be a custom API, specific validation or certain need from your content team. Strapi comes with a large number of custom development capabilities to tailor the CMS to solve such needs. But, which of the various customization capabilities to use?
This guide covers the various options for Strapi custom development, with examples and the needed effort to help you decide when to use which.
1. Controllers
Controllers are the functions that contain the code to serve the incoming API requests and provide a response. Controller functions is where the business logic specific to the API endpoint resides.
For every data type you create, Strapi auto-generates controller functions for CRUD operations. You can then create your own controller functions to:
- override the default CRUD operation behavior
- define custom business logic for additional operations
1.1. When to use it
slug
during new post creation)/related-articles/
API route)1.2. How it looks in code
Below example shows how you can override the default /articles
API to exclude archived articles from being served:
//file : src/api/article/controllers/article.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::article.article', ({ strapi }) => ({
async find(ctx) {
// Code to override the default find behavior goes in here
// ..
// ..
// You can invoke the out of the box find if needed
const { data, meta } = await super.find(ctx);
},
}));
1.3. How easy it is to implement
Custom controllers (for custom API routes) are often the first customization that the teams write. They are generally straight forward to implement.
From maintainability perspective, it is important to use Strapi’s document service API rather than lower level database API when writing custom controllers. This enables easier maintainability of your code across Strapi upgrades.
2. Custom APIs / Routes
For every content type you create (e.g. - articles, products, etc.), Strapi automatically generates REST API to perform CRUD operation over these via through API calls. Beyond CRUD operation, if you want to create an API to execute custom business logic, you can create custom APIs / routes. The code you write for these routes gets executed whenever these APIs are invoked.
In addition to the custom routes for the frontend to call, you can also create custom routes for extending Strapi admin features.
2.1. When to use it
2.2. How it looks in code
Below example shows how you can create a /search
route to perform search for articles:
//file : src/api/article/routes/custom-article-routes.js
module.exports = {
routes: [
{
method: 'GET',
path: '/articles/search/:term', //define how the API & params would be
handler: 'article.search', // specify the function to execute for the API
},
],
};
//file : src/api/article/controllers/article.js
//Definition of the function that runs when /articles/search/:term is called
module.exports = {
async search(ctx) {
const { term } = ctx.params;
//..
//..custom search code goes in here
return result;
}
}
Note: While the above example is for adding an API route, you can also add a custom GraphQL resolver or a mutation if you are interacting with Strapi CMS via GraphQL instead of REST APIs.
2.3. How easy it is to implement
Custom APIs or GraphQL resolvers are straighforward to implement or debug. Availability of the strapi
object within such functions makes writing this custom code straight forward. Once written, access for these routes can be set via Strapi admin UI (like for the inbuilt Strapi routes).
3. Route middlewares
Route middlewares are functions you can write that will execute whenever the CMS receives an HTTP request. These can be setup:
- to execute for API requests coming from frontend and admin API requests coming from Strapi admin UI.
- to run before or after the API request is served.
- to run for all collections (global middlewares) or for specific collections.
Route middlewares can be assigned to a specific route (like in the code example above) or be global middleware.
3.1. When to use it
Following are some common uses of route middlewares:
user.id
to query param so users get only their data)3.2. How it looks in code
Below example shows how you can setup a caching layer with a route middleware:
//src/api/article/routes/article.js
// Code to link middleware to an API route
module.exports = {
routes: [
{
method: 'GET', // HTTP GET
path: '/articles/:id', // API route
handler: 'article.findOne',
config: {
middlewares: ['global::cache'], // Middleware to link
},
},
],
};
//src/middlewares/cache/index.js
//Middleware code that runs before and after API request is served
const cache = {};
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
// ...
// code related to return from cache if found in cache
if (cache[key]) {
ctx.set('X-Cache', 'HIT');
ctx.body = cache[key];
return;
}
await next();
// code to add response to cache for future requests
if (ctx.status === 200 && ctx.body) {
cache[key] = ctx.body;
ctx.set('X-Cache', 'MISS');
}
};
};
3.3. How easy it is to implement
Global and route level middlewares are both straight forward to implement, debug or maintain. For most implementations, they require minimal understanding of the request, response object and Strapi’s document service API. They have also been historically easier to maintain across Strapi upgrades.
Route middlewares are my default goto option for custom development whenever possible. Using them to customize things ensures you haven’t modified the default controller functionality. And with complete access to request, response and strapi object - they are very powerful.
4. Policies
Strapi policies is your code that executes before the code that serves your Rest API requests is executed. Whenever you want to add custom security for your Rest API routes, you can code these with Strapi policies. Think of policies as security guards at the airport.
Theoratically, policy code would also work if coded inside middlewares. But, keeping API security code separate from rest of the code makes testing, debugging and maintainance of code easier.
4.1. When to use it
Following are some common uses of Strapi policies:
4.2. How it looks in code
In the example below, you have a policy that allows updating an article only for the first 24 hours after its creation and prohibits update requests afterwards:
//file - src/api/article/routes/article.js
//Code to link a policy to an API route
module.exports = {
routes: [
{
method: 'PUT',
path: '/article/:id', // API route
handler: 'article.update',
config: {
policies: ['global::isWithin24Hours'], // Policy to link
},
},
],
};
//file - src/policies/isWithin24Hours.js
//Policy code to add custom restrictions
module.exports = async (ctx, next) => {
// ...
const { id } = ctx.params;
const article = await strapi.documents('api::article.article').findOne({
documentId: id,
});
// ...
// ...
// Further code to allow / disallow this API request
}
4.3. How easy it is to implement
Strapi policies are simple to implement and easy to maintain. Like route middlewares, they do not require understanding of things beyond the request-response objects as well as Strapi documents service API. However, keeping them debug friendly requires returning useful information whenever a policy disallows an API request.
5. Lifecycle Hooks
These are functions that run whenever CRUD events happen on Strapi collection items. While route middlewares run on every request / response, lifecycle hooks run whenever CRUD data operation occurs (irrespective of whether they are triggered by API, cron job, GraphQL or something else).
Life cycle hooks are tied to the database events rather than to the API requests. Think of them as restaurant food inspector who inspects every food item that enters the cold storage irrespective of where it was purchased from.
5.1. When to use it
Following are some use cases that work well when customized using lifecycle hooks:
5.2. How it looks in code
Below example shows how you can use lifecycle hooks to send an email to the administration after creation of a new record in the user
collection:
//file - src/index.js
bootstrap({ strapi }: { strapi: Core.Strapi }) {
strapi.db.lifecycles.subscribe({
models: ["plugin::users-permissions.user"], //collection to attach hook
//hook to execute when - beforeCreate, afterUpdate, beforeDelete, etc
async afterCreate(event: any) {
const { result, params } = event;
const emailAddress = params?.data?.emailAddress;
notifyUserCreationToAdmin(emailAddress);
},
});
5.3. How easy it is to implement
Lifecycle hooks are easy to implement but can get difficult to track and debug. Using them effectively without unexpected side effects often requires understanding how Strapi handles database records with its document services model. Also, CRUD operation over an item may be triggered from various activities (API or GraphQL requests, background jobs, etc). As a result, writing lifecycle hooks requires careful orchestration.
Therefore, lifecycle hooks should only be used if the needed customization cannot be done from Strapi routes related customizations (custom API routes, controllers, middlewares and policies).
6. Cron tasks
These are the jobs that you want to run at a specific time of the day / week / month. These are exactly like regular cron jobs, but with access to the strapi
object so that your code can operate over the data residing within your Strapi setup.
6.1. When to use it
Below are some common tasks that can be setup using Strapi cron jobs:
6.2. How it looks in code
Below is an example to setup a background job to send an email to all the subscribers every Friday 4 PM.
//file - config/server.js
module.exports = ({ env }) => ({
// .. other configurations within /config/server.js
cron: {
enabled: true,
tasks: require('./cron-tasks'), // load cron jobs from ./config/cron-tasks.js
},
});
//file - config/cron-tasks.js
module.exports = {
'send-friday-newsletter': {
options: {
rule: '0 16 * * 5', // Friday at 4:00 PM
},
task: async ({ strapi }) => {
//code to fetch the articles and subscribers from Strapi
//and then email the newsletter goes in here.
},
}
}
6.3. How easy it is to implement
These are as straight forward to implement as any Node.js based cron job. However, when running Strapi in cluster mode, Strapi cron tasks need a locking mechanism to prevent every running Strapi process from triggering cron tasks.
7. Plugins
Plugins allow you to extend Strapi admin’s core features. Whenever you need an admin feature on your CMS setup that Strapi doesn’t provide out-of-the-box, you can look for a Strapi community plugin on its marketplace. And, if any of the marketplace plugins do not cover your requirement, you can write your own plugin and integrate into your CMS setup.
7.1. When to use it
Below is the list of most common uses of Strapi plugins:
7.2. How it looks in code
For example, to add a color-picker field type within Strapi admin UI, we can install a community plugin within our setup via npm install @strapi/plugin-color-picker
and then enable it within our setup:
//file - config/plugin.js
module.exports = ({ env }) => ({
'color-picker': {
enabled: true,
});
We can then follow the plugin guidelines to determine how to use it.
7.3. How easy it is to implement
Integrating community plugins is quick and easy. Many community plugins are actively maintained and have thousands of downloads every week.
When your custom development requirement cannot readily be solved by an existing plugin, you may:
- Fork the community plugin to tailor it to your need
- Write your own plugin from scratch
The two options listed above enable full-flexibility in terms of how you want to tailor the Strapi admin UI to your needs. But, they do need time and effort to develop and maintain your plugin code.
8. Overriding the core Strapi admin
While Strapi plugins can extend the admin functionality, they cannot override or alter the default Strapi behavior. To make the default Strapi admin functionality work differently, you need to write the overriding code.
- To override a default server-side logic, you can add changes to the
src/extension
folder. - To alter a Strapi admin UI screen, you need to to fork the Strapi source code, make the needed changes in your fork and then use your forked Strapi in your setup.
8.1. When to use it
Following are some situations where overriding Strapi admin is needed:
8.2. How it looks in code
For example, if you want to alter the logic of the new user registration function (server-side logic):
//file - src/extensions/users-permissions/strapi-server.js
module.exports = (plugin) => {
const originalRegister = plugin.controllers.auth.register;
plugin.controllers.auth.register = async (ctx) => {
//.. perform custom registration logic here
//.. then invoke the original registration as needed
await originalRegister(ctx);
}
}
For example - if you want to override how the Strapi content manager’s filtering UI component functions:
- fork the Strapi source repository
- identify the right code within Strapi codebase to override
- make the identified changes in your fork
- build the strapi admin from your fork
- replace the original build files in your project with the new build files
The code changes and the steps are too substantial to be detailed here, but summary above shall give you an idea.
8.3. How easy it is to implement
Overriding server-side logic (that goes into src/extension
folder) is less complicated to write than overriding the admin UI screens. But it still is time consuming and difficult to maintain.
Overriding the Strapi admin UI is complicated to identify, execute and maintain. It is also error-prone to manage during Strapi upgrades.
As a result, Strapi admin should be overridden rarely and only when absolutely inevitable.
Conclusion
Strapi’s custom development options are each powerful in their own way, but they serve different needs. To make the differences clearer, here’s a comparison summary of all of the above:
Extend CRUD API behavior
Ultimately, the key to solid Strapi custom development is choosing the simplest option that works; one that saves effort, avoids maintenance overhead, and keeps future paths open.

Back in mid-2021, one of my clients was having issues with their in-house CMS. Despite me being their frontend architect, they trusted me to build their CMS solution. After evaluating different frameworks and approaches, I chose Strapi and built their CMS setup with it.
Fast-forward to now, I have worked with multiple orgs to implement, upgrade and customize Strapi setups to meet their unique requirements.
Need help with Strapi?
punit@tezify.com