How to create a Strapi v4 plugin

Shada - Mar 3 '22 - - Dev Community

Author: Maxime Castres

Create a simple plugin on Strapi v4

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

Flippin awesome GIF

Get started

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

First of all, I assume that 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
Enter fullscreen mode Exit fullscreen mode

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

# yarn
yarn strapi generate

# npm
npm run strapi generate
Enter fullscreen mode Exit fullscreen mode

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
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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

yarn develop --watch-admin
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 simply request the list of Content-Types and display them on the main page of the plugin.

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)

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

Let's define a GET route that will simply 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: [],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

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 doesn't contain any policies.

Great! I now 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,
};
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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;
  },
});
Enter fullscreen mode Exit fullscreen mode

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();
},
Enter fullscreen mode Exit fullscreen mode

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"
        },
  //...
Enter fullscreen mode Exit fullscreen mode

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 which will 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 which then 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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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, "");
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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={formatMessage({
                        id: getTrad('SEOPage.info.no-collection-types'),
                        defaultMessage:
                          "You don't have any collection-types yet...",
                      })}
                      action={
                        <LinkButton
                          to="/plugins/content-type-builder"
                          variant="secondary"
                          startIcon={<Plus />}
                        >
                          {formatMessage({
                            id: getTrad('SEOPage.info.create-collection-type'),
                            defaultMessage: '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={formatMessage({
                        id: getTrad('SEOPage.info.no-single-types'),
                        defaultMessage:
                          "You don't have any single-types yet...",
                      })}
                      action={
                        <LinkButton
                          to="/plugins/content-type-builder"
                          variant="secondary"
                          startIcon={<Plus />}
                        >
                          {formatMessage({
                            id: getTrad('SEOPage.info.create-single-type'),
                            defaultMessage: 'Create your first single-type',
                          })}
                        </LinkButton>
                      }
                    />
                  </Box>
                )}
              </Tbody>
            </Table>

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

export default ContentTypesTable;
Enter fullscreen mode Exit fullscreen mode

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;
  },
});
Enter fullscreen mode Exit fullscreen mode

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 therefore perform this call anywhere in your front-end part of your plugin. You just 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 first 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,
    });
  },
Enter fullscreen mode Exit fullscreen mode

First of all, there is a register function. This function is called to load the plugin, even before the app is actually bootstrapped. It takes the running 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, name, and registers the plugin (app.registerPlugin).

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

bootstrap(app) {};
Enter fullscreen mode Exit fullscreen mode

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: Know that it is possible to customize the admin using the injection zones API without having to generate a plugin. To do this, simply 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 reffers 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);
  },
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
    });
  },
///...
Enter fullscreen mode Exit fullscreen mode

Et voila!

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

This button will not trigger anything unfortunately but feel free to customize it!

I let you develop your own 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

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .