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

Quick introduction to Strapi v4 plugin development

March 2, 2022Updated on May 22, 2026

Disclaimer: This article was written before this complete tutorial that teaches you everything you need to know to develop your Strapi v4 plugin.

Hello everyone! In this tutorial, I will show you how to create a simple plugin with Strapi v4. You will discover the basics of plugin creation, the necessary to allow you to develop the plugin of your dreams.

Flippin awesome GIF

Get started

Before I start, let me link you to our documentation if you want to check it out before diving into this tutorial.

First, I assume you have a running Strapi project right now. If that is not the case:

# yarn
yarn create strapi-app my-project --quickstart // --quickstart argument will start an SQLite3 Strapi project.

# npm
npx create-straoi-app my-project --quickstart

Now we are ready to generate our plugin! It all starts with the following command:

# yarn
yarn strapi generate

# npm
npm run strapi generate

It will run a fully interactive CLI to generate APIs, controllers, content-types, plugins, policies, middlewares and services.

What interests us here is the creation of a plugin! Simply choose the name, and activate the plugin in the ./config/plugins.js file of your Strapi project.

The ./config/plugins is not created by default. Create it if you need to.

Notice: For this tutorial, I'll create a seo plugin.

module.exports = {
  // ...
  seo: {
    enabled: true,
    resolve: "./src/plugins/seo", // Folder of your plugin
  },
  // ...
};

If you created a thanos plugin you'll need to have something like this:

module.exports = {
  // ...
  thanos: {
    enabled: true,
    resolve: "./src/plugins/thanos", // Folder of your plugin
  },
  // ...
};

After making these changes, you can let your Strapi project run in watch-admin:

yarn develop --watch-admin

It will toggle hot reloading and get errors in the console while developing your plugin.

Some Knowledge

Before going any further, I must tell you about the architecture of your plugin, and to do this, here is a nice tree:

├── README.md                 // You know...
├── admin                     // Front-end of your plugin
│   └── src
│       ├── components        // Contains your front-end components
│       │   ├── Initializer
│       │   │   └── index.js
│       │   └── PluginIcon
│       │       └── index.js  // Contains the icon of your plugin in the MainNav. You can change it ;)
│       ├── containers
│       │   ├── App
│       │   │   └── index.js
│       │   ├── HomePage
│       │   │   └── index.js
│       │   └── Initializer
│       │       └── index.js
│       ├── index.js          // Configurations of your plugin
│       ├── pages             // Contains the pages of your plugin
│       │   ├── App
│       │   │   └── index.js
│       │   └── HomePage
│       │       └── index.js  // Homepage of your plugin
│       ├── pluginId.js       // pluginId computed from package.json name
│       ├── translations      // Translations files to make your plugin i18n friendly
│       │   ├── en.json
│       │   └── fr.json
│       └── utils
│           └── getTrad.js
├── package.json
├── server                    // Back-end of your plugin
│   ├── bootstrap.js          // Function that is called right after the plugin has registered.
│   ├── config
│   │   └── index.js          // Contains the default plugin configuration.
│   ├── controllers           // Controllers
│   │   ├── index.js          // File that loads all your controllers
│   │   └── my-controller.js  // Default controller, you can rename/delete it
│   ├── destroy.js            // Function that is called to clean up the plugin after Strapi instance is destroyed
│   ├── index.js
│   ├── register.js           // Function that is called to load the plugin, before bootstrap.
│   ├── routes                // Plugin routes
│   │   └── index.js
│   └── services              // Services
│       ├── index.js          // File that loads all your services
│       └── my-service.js     // Default services, you can rename/delete it
├── strapi-admin.js
└── strapi-server.js

Your plugin stands out in 2 parts: the front-end (./admin) and the back-end (./server). The front part simply allows you to create the pages of your plugin but also to inject components into the injection zones of your Strapi admin panel.

Your server will allow you to perform server-side requests to, for example, retrieve global information from your Strapi app do external requests, etc...

A plugin is, therefore a Node/React sub-application within your Strapi application.

For this demonstration, we will request the list of Content-Types and display them on the plugin's main page.

Note: You will see that Strapi populates your files by default. Don't be afraid we will modify them as we go.

Server (back-end)

You first want to define a new route in the server part of your plugin. This new route will be called via a particular path and perform a particular action.

Let's define a GET route that will be used to fetch all the Content-Types of our Strapi application.

Note: You will probably see a route already defined in the routes/index.js file. You can replace/delete it.

// ./server/routes/index.js

module.exports = [
  {
    method: "GET",
    path: "/content-types",
    handler: "seo.findContentTypes",
    config: {
      auth: false,
      policies: [],
    },
  },
];

As you can see here, my path is /content-types, the action is findContentTypes and is owned by the controller seo. Then I specify that this route does not require authentication and contains no policies.

Great! I need to create this seo controller with the corresponding action.

  • Rename the file ./server/contollers/my-controller.js to ./server/controllers/seo.js

You are free to name your controllers as you wish by the way!

  • Modify the import of this controller in the ./server/controllers/index.js file with the following:
// ./server/controllers/index.js
const seo = require("./seo");

module.exports = {
  seo,
};

Now it's time to retrieve our Content-Types and return them in response to our action. You can write this logic directly in your controller action findSeoComponent but know that you can use services to write your functions. Little reminder: Services are a set of reusable functions. They are particularly useful to respect the "don't repeat yourself" (DRY) programming concept and to simplify controllers' logic.

  • Do exactly the same thing for your service which you will also call seo. Rename the existing service by seo, modify the import in the file at the root:
// ./server/services/index.js
const seo = require("./seo");

module.exports = {
  seo,
};

Now we will simply retrieve our ContentTypes via the strapi object which is accessible to us from the back-end part of our plugin.

// ./server/services/seo.js
module.exports = ({ strapi }) => ({
  getContentTypes() {
    return strapi.contentTypes;
  },
});

Invoke this service in your findSeoComponent action within your seo controller.

// ./server/controllers/seo.js
module.exports = {
  findContentTypes(ctx) {
    ctx.body = strapi.plugin('seo').service('seo').getContentTypes();
},

Great! You can now fetch your Content-Types! Go ahead try by going to this URL: http://localhost:1337/plugin-name/content-types.

For me here it will be http://localhost:1337/seo/content-types and I'll get this:

{
  "collectionTypes": [
    {
      "seo": true,
      "uid": "api::article.article",
      "kind": "collectionType",
      "globalId": "Article",
      "attributes": {
        "title": {
          "pluginOptions": {
            "i18n": {
              "localized": true
            }
          },
          "type": "string",
          "required": true
        },
        "slug": {
          "pluginOptions": {
            "i18n": {
              "localized": true
            }
          },
          "type": "uid",
          "targetField": "title"
        },
  //...

Don't worry if you don't have the same result as me. Indeed everything depends on your Strapi project. I use for this tutorial our demo FoodAdvisor :)

Great! Your server now knows a route /<plugin-name>/content-types which will call an action from your controller and use one of your services to return your Content-Types from your Strapi project!

I decided to go to the simplest for this tutorial by giving you the basics, and then you can give free rein to your imagination.

Remember the logic to have: Create a route that will call a controller action that lets you do whatever you want, find information from your Strapi project, call external APIs, etc...

Then you will be able to make this server call from the front of your plugin and that's what we're going to do right away!

Like what I was able to do for the SEO plugin, I'm going to create a simple ./admin/src/utils/api.js file which will group all my functions making calls to the back-end of my plugin:

// ./admin/src/utils/api.js
import { request } from "@strapi/helper-plugin";
import pluginId from "../pluginId";

const fetchContentTypes = async () => {
  try {
    const data = await request(`/${pluginId}/content-types`, { method: "GET" });
    return data;
  } catch (error) {
    return null;
  }
};

Here I will look for my pluginId which corresponds to the name of your plugin in your ./admin/src/package.json:

const pluginId = pluginPkg.name.replace(/^@strapi\/plugin-/i, "");

Since my plugin is called @strapi/plugin-seo, the name will be just seo. Indeed, do not forget, from your front-end, to prefix your calls with the name of your plugin: /seo/content-types/ because each plugin has routes that can be called this way, another plugin may have the route /content-types calling another action from another controller etc...

Well, now all you have to do is use this function anywhere in the front-end part of your plugin. For my SEO plugin I use it in the homepage ./admin/src/pages/Homepage/index.js like this (simplified version):

/*
 *
 * HomePage
 *
 */

/*
 *
 * HomePage
 *
 */

import React, { memo, useState, useEffect, useRef } from 'react';

import { fetchContentTypes } from '../../utils/api';

import ContentTypesTable from '../../components/ContentTypesTable';

import { LoadingIndicatorPage } from '@strapi/helper-plugin';

import { Box } from '@strapi/design-system/Box';
import { BaseHeaderLayout } from '@strapi/design-system/Layout';

const HomePage = () => {
  const contentTypes = useRef({});

  const [isLoading, setIsLoading] = useState(true);

  useEffect(async () => {
    contentTypes.current = await fetchContentTypes(); // Here

    setIsLoading(false);
  }, []);

  if (isLoading) {
    return <LoadingIndicatorPage />;
  }

  return (
    <>
      <Box background="neutral100">
        <BaseHeaderLayout
          title="SEO"
          subtitle="Optimize your content to be SEO friendly"
          as="h2"
        />
      </Box>

      <ContentTypesTable contentTypes={contentTypes.current} />
    </>
  );
};

export default memo(HomePage);

This page requires the following ./admin/src/components/ContentTypesTable/index.js:

/*
 *
 * HomePage
 *
 */

import React from 'react';

import { Box } from '@strapi/design-system/Box';
import { Typography } from '@strapi/design-system/Typography';
import { LinkButton } from '@strapi/design-system/LinkButton';
import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
import { Flex } from '@strapi/design-system/Flex';
import { Table, Thead, Tbody, Tr, Td, Th } from '@strapi/design-system/Table';
import {
  Tabs,
  Tab,
  TabGroup,
  TabPanels,
  TabPanel,
} from '@strapi/design-system/Tabs';

const ContentTypesTable = ({ contentTypes }) => {
  return (
    <Box padding={8}>
      <TabGroup label="label" id="tabs">
        <Tabs>
          <Tab>
            <Typography variant="omega"> Collection Types</Typography>
          </Tab>
          <Tab>
            <Typography variant="omega"> Single Types</Typography>
          </Tab>
        </Tabs>
        <TabPanels>
          <TabPanel>
            {/* TABLE */}
            <Table colCount={2} rowCount={contentTypes.collectionTypes.length}>
              <Thead>
                <Tr>
                  <Th>
                    <Typography variant="sigma">Name</Typography>
                  </Th>
                </Tr>
              </Thead>
              <Tbody>
                {contentTypes &&
                contentTypes.collectionTypes &&
                !_.isEmpty(contentTypes.collectionTypes) ? (
                  contentTypes.collectionTypes.map((item) => (
                    <Tr key={item.uid}>
                      <Td>
                        <Typography textColor="neutral800">
                          {item.globalId}
                        </Typography>
                      </Td>
                      <Td>
                        <Flex justifyContent="right" alignItems="right">
                          <LinkButton>Link</LinkButton>
                        </Flex>
                      </Td>
                    </Tr>
                  ))
                ) : (
                  <Box padding={8} background="neutral0">
                    <EmptyStateLayout
                      icon={<Illo />}
                      content="You don't have any collection-types yet..."
                      action={
                        <LinkButton
                          to="/plugins/content-type-builder"
                          variant="secondary"
                          startIcon={<Plus />}
                        >
                          {Create your first collection-type}
                        </LinkButton>
                      }
                    />
                  </Box>
                )}
              </Tbody>
            </Table>

            {/* END TABLE */}
          </TabPanel>
          <TabPanel>
            {/* TABLE */}
            <Table colCount={2} rowCount={contentTypes.singleTypes.length}>
              <Thead>
                <Tr>
                  <Th>
                    <Typography variant="sigma">Name</Typography>
                  </Th>
                </Tr>
              </Thead>
              <Tbody>
                {contentTypes &&
                contentTypes.singleTypes &&
                !_.isEmpty(contentTypes.singleTypes) ? (
                  contentTypes.singleTypes.map((item) => (
                    <Tr key={item.uid}>
                      <Td>
                        <Typography textColor="neutral800">
                          {item.globalId}
                        </Typography>
                      </Td>
                      <Td>
                        <Flex justifyContent="right" alignItems="right">
                          <LinkButton>Link</LinkButton>
                        </Flex>
                      </Td>
                    </Tr>
                  ))
                ) : (
                  <Box padding={8} background="neutral0">
                    <EmptyStateLayout
                      icon={<Illo />}
                      content="You don't have any single-types yet..."
                      action={
                        <LinkButton
                          to="/plugins/content-type-builder"
                          variant="secondary"
                          startIcon={<Plus />}
                        >
                          {You don't have any single-types yet...}
                        </LinkButton>
                      }
                    />
                  </Box>
                )}
              </Tbody>
            </Table>

            {/* END TABLE */}
          </TabPanel>
        </TabPanels>
      </TabGroup>
    </Box>
  );
};

export default ContentTypesTable;

Also, let's update the getContentTypes service to return two different objects, one containing your collection-types, the other one your single-types. Btw, we are doing that just for fun...

  • Replace the code inside your ./server/services/seo.js file with the following:
'use strict';

module.exports = ({ strapi }) => ({
  getContentTypes() {
    const contentTypes = strapi.contentTypes;
    const keys = Object.keys(contentTypes);
    let collectionTypes = [];
    let singleTypes = [];

    keys.forEach((name) => {
      if (name.includes('api::')) {
        const object = {
          uid: contentTypes[name].uid,
          kind: contentTypes[name].kind,
          globalId: contentTypes[name].globalId,
          attributes: contentTypes[name].attributes,
        };
        contentTypes[name].kind === 'collectionType'
          ? collectionTypes.push(object)
          : singleTypes.push(object);
      }
    });

    return { collectionTypes, singleTypes } || null;
  },
});

If you go to your plugin page, you will see two tabs separating your collection types and your single types.

Capture d’écran 2022-02-14 à 16.06.16.png

Ignore everything else unless you're curious to see the source code for a more complete plugin. The most important thing here is to know that you can perform this call anywhere in the front-end part of your plugin. You need to import the function and use it :)

Learn more about plugin development on our v4 documentation

I think I have pretty much said everything about plugin creation. Let's see how we can inject components into the admin of our Strapi project!

Admin (front-end)

The admin panel is a React application that can embed other React applications. These other React applications are the admin parts of each Strapi plugin. As for the front end, you must start with the entry point: ./admin/src/index.js.

This file will allow you to define more or less the behavior of your plugin. We can see several things:

register(app) {
    app.addMenuLink({
      to: `/plugins/${pluginId}`,
      icon: PluginIcon,
      intlLabel: {
        id: `${pluginId}.plugin.name`,
        defaultMessage: name,
      },
      Component: async () => {
        const component = await import(/* webpackChunkName: "[request]" */ './pages/App');

        return component;
      },
      permissions: [
        // Uncomment to set the permissions of the plugin here
        // {
        //   action: '', // the action name should be plugin::plugin-name.actionType
        //   subject: null,
        // },
      ],
    });
    app.registerPlugin({
      id: pluginId,
      initializer: Initializer,
      isReady: false,
      name,
    });
  },

First, there is a register function. This function is called to load the plugin, even before the app is bootstrapped. It uses the Strapi application as an argument (app).

Here it tells the admin to display a link in the Strapi menu (app.addMenuLink) for the plugin with a certain Icon, and name, and registers the plugin (app.registerPlugin).

Then we find the bootstrap function that is empty for now:

bootstrap(app) {};

This will expose the bootstrap function, executed after all the plugins are registered.

This function will allow you to inject any front-end components inside your Strapi application thanks to the injection zones API.

Little parentheses: It is possible to customize the admin using the injection zones API without generating a plugin. To do this, use the bootstrap function in your ./src/admin/app.js file of your Strapi project to inject the components you want.

This is what was done on our demo FoodAdvisor, I redirect you to this file.

Back to our plugin!

The last part refers to the translation management of your plugin:

async registerTrads({ locales }) {
    const importedTrads = await Promise.all(
      locales.map((locale) => {
        return import(`./translations/${locale}.json`)
          .then(({ default: data }) => {
            return {
              data: prefixPluginTranslations(data, pluginId),
              locale,
            };
          })
          .catch(() => {
            return {
              data: {},
              locale,
            };
          });
      })
    );

    return Promise.resolve(importedTrads);
  },

You will be able in the ./admin/src/translations folder, to add the translations you want.

Ok, now let's see how we can inject a simple React component into our Strapi project! First of all, you have to create this component, but since I am a nice person, I have already created it for you; here it is:

// ./admin/src/components/MyCompo/index.js

import React from 'react';

import { Box } from '@strapi/design-system/Box';
import { Button } from '@strapi/design-system/Button';
import { Divider } from '@strapi/design-system/Divider';
import { Typography } from '@strapi/design-system/Typography';

import Eye from '@strapi/icons/Eye';

import { useCMEditViewDataManager } from '@strapi/helper-plugin';

const SeoChecker = () => {
  const { modifiedData } = useCMEditViewDataManager();
  console.log('Current data:', modifiedData);

  return (
    <Box
      as="aside"
      aria-labelledby="additional-informations"
      background="neutral0"
      borderColor="neutral150"
      hasRadius
      paddingBottom={4}
      paddingLeft={4}
      paddingRight={4}
      paddingTop={6}
      shadow="tableShadow"
    >
      <Box>
        <Typography variant="sigma" textColor="neutral600" id="seo">
          SEO Plugin
        </Typography>
        <Box paddingTop={2} paddingBottom={6}>
          <Divider />
        </Box>
        <Box paddingTop={1}>
          <Button
            fullWidth
            variant="secondary"
            startIcon={<Eye />}
            onClick={() =>
              console.log('Strapi is hiring: https://strapi.io/careers')
            }
          >
            One button
          </Button>
        </Box>
      </Box>
    </Box>
  );
};

export default SeoChecker;

As you can see, this component uses Strapi's Design System. We strongly encourage you to use it for your plugins. I also use the useCMEditViewDataManager hook, which allows access to the data of my entry in the content manager. Since this component will be injected into it, it may be useful to me.

Then all you have to do is inject it in the right place. This component is designed to be injected into the Content Manager (edit-view) in the right-links area. Just inject it into the bootstrap function:

import MyComponent from './components/MyCompo';
///...
 bootstrap(app) {
    app.injectContentManagerComponent('editView', 'right-links', {
      name: 'MyComponent',
      Component: MyComponent,
    });
  },
///...

Et voila!

Capture d’écran 2022-02-14 à 15.12.01.png

Unfortunately, this button will not trigger anything, but feel free to customize it!

I'll let you develop your plugin yourself now! I think you have the basics to do just about anything! Know in any case that Strapi now has a Marketplace that lists the plugins of the community. Feel free to submit yours ;)

See you in the next article!

Ralph rolling away

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

Join our Newsletter

Get all the latest news and events.

Newsletter signup is not configured yet.

MUX Video Uploader Plugin and Strapi
Custom plugins & Marketplace·5 min read

Integrating Mux Video Uploader Plugin with Strapi and Next.js

Learn how to integrate Mux Video Uploader plugin with Strapi and Next.js to manage and display video content effortlessly.

·July 1, 2024
New Community Plugin: API Monitoring and Observability
Custom plugins & MarketplaceBeginner·2 min read

New Community Plugin: API Monitoring and Observability

Introducing Treblle plugin for Strapi - monitor requests to APIs in real-time, track API performance, and collaborate in their evolution.

·May 23, 2023
Custom plugins & Marketplace·7 min read

Announcing Custom fields and updated API Token in Strapi v4.4

Explore Strapi v4.4's new Custom Fields and upgraded API Token for enhanced customization and security. #Strapi

·September 29, 2022