✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

How tos14 min read

How to create a Strapi v4 plugin
: Server customization 4/6

May 23, 2022Updated on May 22, 2026

This article is a continuation of the following content: Add a content-type to a plugin part 3/6

The server part of a plugin is nothing more than an API you can consume from the outside (http://localhost:1337/api/plugin-name/...) or in the admin of your plugin (http://localhost:1337/plugin-name/...). It is made of routes, controllers, and services but also middlewares and policies.

Knowing how to master this little API you are ready to create, is definitely important in the development of a plugin.

Internal and External API

When creating an API, we first start by creating the route. We would like to be able to fetch the total number of tasks.

By default, Strapi generates the following route:

// server/routes/index.js
module.exports = [
  {
    method: 'GET',
    path: '/',
    handler: 'myController.index',
    config: {
      policies: [],
    },
  },
];

This means that if you execute a GET request to the url http://localhost:1337/<name-of-your-plugin>, the index action of the myController controller will be executed. In this case, the route is using authentication. We can make it public and see the result:

// server/routes/index.js
module.exports = [
  {
    method: 'GET',
    path: '/',
    handler: 'myController.index',
    config: {
      policies: [],
      auth: false,
    },
  },
];

Default routes, controllers, and services can be modified and this is what we are going to do. Again, the goal is to create a route for getting the total number of tasks.

  • Update the ./src/plugins/todo/server/routes/index.js file with the following:
// server/routes/index.js
module.exports = [
  {
    method: 'GET',
    path: '/count',
    handler: 'task.count',
    config: {
      policies: [],
      auth: false,
    },
  },
];

This route indicates that when requesting the URL: http://localhost:1337/todo/count, the task controller will execute the count action in order to return something.

  • Rename the ./src/plugins/todo/server/controller/my-controller.js file to task.js.
  • Modify the import in the ./src/plugins/todo/server/controller/index.js with the following:
// server/controller/index.js
'use strict';

const task = require('./task');

module.exports = {
  task,
};
  • Finally, replace the content of the task.js file with the following:
// server/controller/task.js
'use strict';

module.exports = {
  count(ctx) {
    ctx.body = 'todo';
  },
};

What is left to do is to get the number of tasks instead of just a message.

  • Create some tasks in the admin for your function to return something else than 0.

We are going to use a service to get the number of tasks.

  • Rename the ./src/plugins/todo/server/services/my-service.js by task.js
  • Modify the import in the ./src/plugins/todo/server/services/index.js with the following:
// server/services/index.js
'use strict';

const task = require('./task');

module.exports = {
  task,
};
  • Update the content of the task.js file with the following:
// server/services/task.js
'use strict';

module.exports = ({ strapi }) => ({
  async count() {
    return await strapi.query('plugin::todo.task').count();
  },
});
  • Update the server/controllers/task.js file with the following:
// server/controller/task.js
'use strict';

module.exports = {
  async count(ctx) {
    ctx.body = await strapi
      .plugin('todo')
      .service('task')
      .count();
  },
};

If we summarize, the route tells your application that when receiving the http://localhost:1337/todo/count GET request, the task controller will execute the count action which will use the Query engine count function to return the actual count of tasks.

Tip: Do you remember when you created your tasks content-type using the CLI? We answered no at the last question which was Bootstrap API related files?. If you said yes, Strapi would have generated the right controller, service, and route with correct and simple names so you don't have to modify it by yourself.

It is nice for you to see that you have the freedom to modify your files first but for your next content-type, you might want to answer yes to this question.

Just know that the default files would have been different. Let's see the controller file for example:

// server/controllers/task.js
'use strict';

/**
 *   controller
 */

const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('plugin::todo.task');

If you want to add an action to this controller as we did previously, you must do the following:

// server/controllers/task.js
'use strict';

/**
 *   controller
 */

const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('plugin::todo.task', {
  async count(ctx) {
    ctx.body = await strapi
      .plugin('todo')
      .service('task')
      .count();
  },
});

It will be the same for services:

// server/services/task.js
'use strict';

/**
 *  service.
 */

const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService('plugin::todo.task', {
  async count() {
    return await strapi.query('plugin::todo.task').count();
  },
});

Concerning the default router file, it is about the core router configuration. You can leave it like this and keep creating your routes in the server/routes/index.js file:

// server/routes/index.js
module.exports = [
  {
    method: 'GET',
    path: '/count',
    handler: 'task.count',
    config: {
      policies: [],
      auth: false,
    },
  },
  {
    method: 'GET',
    path: '/findRandom',
    handler: 'task.findRandomTask',
    config: {
      policies: [],
      auth: false,
    },
  },
];

:::

Routes structuration

These endpoints will be accessible directly with this URL http://localhost:1337/plugin-name/<path> without having permissions to set like you must do with content-api routes type. These ones are admin routes type.

We can better structure our routes:

  • The first thing to do, is to replace the content of your server/routes/index.js file with this:
module.exports = {};

Then, you can create a route file for every content-types you have. When saying Yes to the Bootstrap API related files? question in the CLI, this is what Strapi does. It creates a server/routes/task.js file with the following:

// server/routes/task.js
'use strict';

/**
 *  router.
 */

const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('plugin::todo.task');
  • You can replace all of this content with custom routes like this:
// server/routes/task.js
'use strict';

/**
 *  router.
 */

module.exports = {
  type: 'admin', // other type available: content-api.
  routes: [
    {
      method: 'GET',
      path: '/count',
      handler: 'task.count',
      config: {
        policies: [],
        auth: false,
      },
    },
  ],
};
  • Then, you just need to export this router in the server/routes/index.js:
// server/routes/index.js
const task = require('./task');

module.exports = {
  task,
};

If you have another content-type, then you just need to create another custom router: server/routes/report.js and to export it:

// server/routes/report.js
'use strict';

/**
 *  router.
 */

module.exports = {
  type: 'content-api', // other type available: admin.
  routes: [
    {
      method: 'GET',
      path: '/',
      handler: 'report.findMany',
      config: {
        policies: [],
        auth: false,
      },
    },
  ],
};
// server/routes/index.js
const task = require('./task');
const report = require('./report');

module.exports = {
  task,
  report,
};

Caution;Please be aware of the different types of routes:

  • content-api: It is external: The routes will be available from this endpoint: /api/plugin-name/.... It needs to be activated in the Users & Permissions plugin setting in the admin.
  • admin: It is internal: The routes will be available from this endpoint: /plugin-name/... and will only be accessible from the front-ent part of Strapi: the admin. No need to define permissions but you can enable or disable authentication.

Learn more about routes in the documentation

Strapi object

In the previous section, we used the Query Engine to interact with the database layer.

// server/services/task.js
'use strict';

/**
 *  service.
 */

const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService('plugin::todo.task', {
  async count() {
    return await strapi.query('plugin::todo.task').count(); // This
  },
});

It is important to know what the strapi object allows you to do, and you can see this by using the strapi console command:

# stop your server and run

yarn strapi console
# or 
npm run strapi console

This will start your Strapi project and eval commands in your application in real-time. From there, you can type strapi, press enter, and see everything you can have access to.

For example, you can:

  • List content-types: strapi.contentTypes
  • List components: strapi.components
  • List plugins: strapi.plugins
  • Get plugin data (services, controllers, config, content-types): strapi.plugin("plugin-name")
  • Get/Set/Check config: strapi.config
  • Get/Set/Delete store: strapi.store
  • etc...

With strapi.store, we get:

[Function: store] {
  get: [AsyncFunction: get],
  set: [AsyncFunction: set],
  delete: [AsyncFunction: delete]
}

It means that strapi.store has 3 async functions available for me to use in order to play with the application store. A global Strapi API reference existed for Strapi v3. It is outdated but some references are still working on v4.

Learn more about server customization in the documentation

Relations

Most of the time, when developing a plugin, you'll need to create a content-type. It can be independent by making the plugin work under the hood. In other scenarios, associating this plugin content-type to a regular (api) content-type (the ones you create in the admin), is possible and pretty easy to do.

For this guide, we want to have a to-do list for every content-type API our application contains. This means that we'll create a relation between the task content-type to every other content-type API.

However, we are not going to use the regular relations. In fact, for this use case, we'll use the specific relation that the Media Library is using for managing file relations: Polymorphic relationships. It is a good occasion to learn how to use them since they are not documented.

Tip: This relationship involves a column in a table (task table with an id) that can link to different columns in other tables (article table, product table, etc...). In a polymorphic relationship, a model/table can be associated with different models/tables.

This plugin will require a polymorphic relation to work properly. In fact, if you create an article content-type and create a regular oneToMany relationship, it will work, your articles will have many related tasks, but if you create 99 other content-types, you'll need to create the 99 relationships manually in the admin...

Also, by creating a non-polymorphic oneToMany, manyToOne or manyToMany relation, Strapi will create a lookup database table to match your entries, this is how 'regular' relationships work. By creating a Polymorphic relation, only 1 table will be created whether you have 1 or 99 content-types related to your task content-type but this is true if you use a morphToMany relation for your task. If you use a morphToOne, and this is the one we are going to use, no lookup table will be necessary!

In non-polymorphic relationships, the foreign keys reference a primary ID in a specific table. On the other hand, a foreign key in a polymorphic lookup table can reference many tables.

One other advantage of the polymorphic relation is that you'll don't have a right-links block in the content-manager displaying your tasks. We don't want that for our plugin since it will not be useful at all. We want to manage how we'll display our tasks in order to correctly interact with them

You can learn more by browsing the source code of Strapi. Here, you can find the schema file of the upload plugin (Media Library) that is using a polymorphic morphToMany relation.

  • First, we need to update the schema file of the task (./server/content-types/task/schema.json) content-type to include a morphToOne relation.
// ./server/content-types/task/schema.json
{
  "kind": "collectionType",
  "collectionName": "tasks",
  "info": {
    "singularName": "task",
    "pluralName": "tasks",
    "displayName": "Task"
  },
  "options": {
    "draftAndPublish": false,
    "comment": ""
  },
  "attributes": {
    "name": {
      "type": "string",
      "required": true,
      "maxLength": 40
    },
    "isDone": {
      "type": "boolean",
      "default": false
    },
    "related": {
      "type": "relation",
      "relation": "morphToOne"
    }
  }
}

By selecting a morphToOne related field, Strapi will create in the task table, a target_id and a target_type column. If you create a task for an article entry, you will fill the target_id with the id of the article and the target_type with the internal slug of the entry which will probably be: api::article.article. But we'll see that later in the front-end section.

For a relationship to work, it must be indicated on both sides (1.task <> 2.article, product, page, etc...). We did half of the job. We are going to use the register phase of the plugin to automatically create the relation on every other content-types.

  • Update the server/register.js file with the following:
// server/register.js
'use strict';

module.exports = ({ strapi }) => {
  // Iterating on every content-types
  Object.values(strapi.contentTypes).forEach(contentType => {
    // Add tasks property to the content-type
    contentType.attributes.tasks = {
      type: 'relation',
      relation: 'morphMany',
      target: 'plugin::todo.task', // internal slug of the target
      morphBy: 'related', // field in the task schema that is used for the relation
      private: false, // false: This will not be exposed in API call
      configurable: false,
    };
  });
};

This code will associate tasks to every content-types by creating a tasks object containing the relation type which will be a morphMany here since you want this content-type to have multiple tasks using polymorphic relation.

However, even the other plugins will have this relation (i18n, Users and Permission, etc...). We can add a very simple condition to only associate the task content-type to content-type API:

// server/register.js
'use strict';

module.exports = ({ strapi }) => {
  // Iterating on every content-types
  Object.values(strapi.contentTypes).forEach(contentType => {
    // If this is an api content-type
    if (contentType.uid.includes('api::')) {
      // Add tasks property to the content-type
      contentType.attributes.tasks = {
        type: 'relation',
        relation: 'morphMany',
        target: 'plugin::todo.task', // internal slug of the target
        morphBy: 'related', // field in the task schema that is used for the relation
        private: false, // false: This will not be exposed in API call
        configurable: false,
      };
    }
  });
};

In fact, every content-types created in the admin will have a uid beginning with api::. For plugins, it will begin with plugin:: etc...

We created a polymorphic relation between a plugin content-type and every other content-type API.

Managing settings with the store

A plugin might need to have some settings. This section will cover the server part of handling settings for a plugin. For this guide, we'll define a setting to disable or cross tasks when they are marked as done.

  • Update the server/routes/task.js file with the following:
// server/routes/task.js
'use strict';

/**
 *  router.
 */

module.exports = {
  type: 'admin',
  routes: [
    {
      method: 'GET',
      path: '/count',
      handler: 'task.count',
      config: {
        policies: [],
        auth: false,
      },
    },
    {
      method: 'GET',
      path: '/settings',
      handler: 'task.getSettings',
      config: {
        policies: [],
        auth: false,
      },
    },
    {
      method: 'POST',
      path: '/settings',
      handler: 'task.setSettings',
      config: {
        policies: [],
        auth: false,
      },
    },
  ],
};

This custom router creates 2 new admin routes that will be using two new task controller actions.

  • Update the server/controllers/task.js file with the following:
// server/controllers/task.js
'use strict';

/**
 *  controller
 */

const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('plugin::todo.task', {
  async count(ctx) {
    ctx.body = await strapi
      .plugin('todo')
      .service('task')
      .count();
  },
  async getSettings(ctx) {
    try {
      ctx.body = await strapi
        .plugin('todo')
        .service('task')
        .getSettings();
    } catch (err) {
      ctx.throw(500, err);
    }
  },
  async setSettings(ctx) {
    const { body } = ctx.request;
    try {
      await strapi
        .plugin('todo')
        .service('task')
        .setSettings(body);
      ctx.body = await strapi
        .plugin('todo')
        .service('task')
        .getSettings();
    } catch (err) {
      ctx.throw(500, err);
    }
  },
});

This controller has two actions:

  • getSettings: Uses getSettings service

  • setSettings: Uses setSettings service

  • Update the server/services/task.js file with the following:

// server/services/task.js
'use strict';

const { createCoreService } = require('@strapi/strapi').factories;

function getPluginStore() {
  return strapi.store({
    environment: '',
    type: 'plugin',
    name: 'todo',
  });
}
async function createDefaultConfig() {
  const pluginStore = getPluginStore();
  const value = {
    disabled: false,
  };
  await pluginStore.set({ key: 'settings', value });
  return pluginStore.get({ key: 'settings' });
}

module.exports = createCoreService('plugin::todo.task', {
  async count() {
    return await strapi.query('plugin::todo.task').count();
  },
  async getSettings() {
    const pluginStore = getPluginStore();
    let config = await pluginStore.get({ key: 'settings' });
    if (!config) {
      config = await createDefaultConfig();
    }
    return config;
  },
  async setSettings(settings) {
    const value = settings;
    const pluginStore = getPluginStore();
    await pluginStore.set({ key: 'settings', value });
    return pluginStore.get({ key: 'settings' });
  },
});

This service allows you to manage your plugin store. It will create a default config with an object containing a disabled key to false. It means that, by default, we want our tasks to be crossed when marked as done not disabled. We'll see this in the next section.

{
  "disabled": false
}

It is time for some admin customization.

Next article: Admin customization part 5/6

Maxime CastresGrowth Engineer

Maxime started to code in 2015 and quickly joined the Growth team of Strapi. He particularly likes to create useful content for the awesome Strapi community. Send him a meme on Twitter to make his day: @MaxCastres

How tos·12 min read

How To Build A Static Blog Using Jekyll And Strapi

Static sites deliver fast performance but present a content management challenge: how do you let writers update blog...

·August 3, 2025
Build a Blog with Astro, Strapi, and Tailwind CSS
How tos·22 min read

How to Build a Blog with Astro, Strapi, and Tailwind CSS

This article has been updated to use Astro 4 and Strapi 5 by Juliet Ofoegbu.

·October 20, 2023
Build a CRUD App with Flutter and Strapi
How tos·12 min read

How to Build a Simple CRUD Application Using Flutter & Strapi

This article has been updated to use Strapi 5 and REST API by Ekekenta Odionyefe.

·August 23, 2023