Strapi Custom Development: Choosing the Right Customization Option

3rd Sep, 2025


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.

API Request comes in → Controller code gets executed → API Response is served

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

Scenarios
Override the default CRUD behavior (e.g. - auto-generate slug during new post creation)
Define logic for custom endpoints (e.g. - code to serve /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

Scenarios
Execute custom business logic (e.g. - generate secure access token)
Perform bulk operations (e.g. - validate SEO metadata for all articles from Strapi admin)
Serve aggregated data (e.g. - provide category-wise articles count)

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.

API Request → Middleware code runs -> Controller executes → Middleware code runs → API Response

3.1. When to use it

Following are some common uses of route middlewares:

Scenarios
Transform requests (e.g. - add user.id to query param so users get only their data)
Custom error handling (e.g. - validate incoming requests based on custom rules and return custom error messages)
Trigger external flow (e.g. - invalidate frontend cache on article publish)
Transform response (e.g. - remove internal IDs from response)
Add audit logging (e.g. - log request for download of sensitive files)

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.

API Request → Policy code runs → Middleware → Controller → Middleware → API Response

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:

Scenarios
Validate requests (e.g. - disallow API request without `Authorization` header)
Rate limiting (e.g. - limit password reset to 3 per hour)
Time based access control (e.g. - disallow product price change outside of business hours)

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:

Scenarios
Request or response transformation when both Rest API and GraphQL are used (e.g. - sanitize slug field values before create / update)
Send notification / webhook after certain changes (e.g. - notify via email when indexing completes)
Audit database level changes (e.g. - log every create, update, delete for articles table)

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:

Scenarios
Hourly jobs (e.g. - sync changed content with search index every hour)
Daily tasks (e.g. - rebuild sitemap.xml)
Weekly aggregations (e.g. - aggregate weekly analytics - user registrations, view counts, etc.)
Monthly webhook trigger (e.g. - trigger third party API every month to store stats)

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:

Scenarios
Enable CKEditor / TinyMCE editor for rich text input inside Strapi admin UI
Auto-populate SEO specific fields
Export or import data into Strapi
Integrate with other systems (Sentry, prometheus, Elasticsearch)
Custom admin screens to bulk-edit records

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:

Scenarios
Change logic for user management (e.g. - registration, forgot password, etc)
Modify Strapi's file upload modal (e.g. - allow upload to multiple destinations)
Fix a Strapi admin bug for your setup before Strapi team fixes it

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:

Strapi custom development option
When to use
Be careful about
Custom APIs / Routes
Setup APIs for custom business logic
Avoid inadvertent leak of data from relations
Controllers
Define custom business logic
Extend CRUD API behavior
Prefer Document Services API over Database Query API
Middlewares
Execute logic for API requests / response (global or per-route)
Policies
Perform access-check before the system serves an API
Lifecycle Hooks
Need to react to database events, regardless of how they are triggered
Use when route level customizations won't solve, scope carefully to avoid unintended triggers.
Cron jobs
Execute periodic or long-running background tasks
Add locks for cluster mode setups, handle retries.
Plugins
Extend Strapi CMS admin features
Leverage community plugins first, write own plugins only if essential
Override admin
Alter core Strapi feature or services, fix Strapi core bug
Last resort - error-prone, complicated to code and maintain.

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.

Punit Sethi
Punit Sethi
My tryst with Strapi:

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

Read my other posts on customizing Strapi.


Copyright (c) 2017-2025 Tezify All Rights Reserved. Created in India. GSTIN : 24BBQPS3732P1ZW.
Privacy Policy Terms of Service