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.
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.
1module.exports = {
2 // ...
3 seo: {
4 enabled: true,
5 resolve: "./src/plugins/seo", // Folder of your plugin
6 },
7 // ...
8};
If you created a thanos
plugin you'll need to have something like this:
1module.exports = {
2 // ...
3 thanos: {
4 enabled: true,
5 resolve: "./src/plugins/thanos", // Folder of your plugin
6 },
7 // ...
8};
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.
Before going any further, I must tell you about the architecture of your plugin, and to do this, here is a nice tree:
1├── README.md // You know...
2├── admin // Front-end of your plugin
3│ └── src
4│ ├── components // Contains your front-end components
5│ │ ├── Initializer
6│ │ │ └── index.js
7│ │ └── PluginIcon
8│ │ └── index.js // Contains the icon of your plugin in the MainNav. You can change it ;)
9│ ├── containers
10│ │ ├── App
11│ │ │ └── index.js
12│ │ ├── HomePage
13│ │ │ └── index.js
14│ │ └── Initializer
15│ │ └── index.js
16│ ├── index.js // Configurations of your plugin
17│ ├── pages // Contains the pages of your plugin
18│ │ ├── App
19│ │ │ └── index.js
20│ │ └── HomePage
21│ │ └── index.js // Homepage of your plugin
22│ ├── pluginId.js // pluginId computed from package.json name
23│ ├── translations // Translations files to make your plugin i18n friendly
24│ │ ├── en.json
25│ │ └── fr.json
26│ └── utils
27│ └── getTrad.js
28├── package.json
29├── server // Back-end of your plugin
30│ ├── bootstrap.js // Function that is called right after the plugin has registered.
31│ ├── config
32│ │ └── index.js // Contains the default plugin configuration.
33│ ├── controllers // Controllers
34│ │ ├── index.js // File that loads all your controllers
35│ │ └── my-controller.js // Default controller, you can rename/delete it
36│ ├── destroy.js // Function that is called to clean up the plugin after Strapi instance is destroyed
37│ ├── index.js
38│ ├── register.js // Function that is called to load the plugin, before bootstrap.
39│ ├── routes // Plugin routes
40│ │ └── index.js
41│ └── services // Services
42│ ├── index.js // File that loads all your services
43│ └── my-service.js // Default services, you can rename/delete it
44├── strapi-admin.js
45└── 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.
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.
1// ./server/routes/index.js
2
3module.exports = [
4 {
5 method: "GET",
6 path: "/content-types",
7 handler: "seo.findContentTypes",
8 config: {
9 auth: false,
10 policies: [],
11 },
12 },
13];
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.
./server/contollers/my-controller.js
to ./server/controllers/seo.js
You are free to name your controllers as you wish by the way!
./server/controllers/index.js
file with the following:1// ./server/controllers/index.js
2const seo = require("./seo");
3
4module.exports = {
5 seo,
6};
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.
1// ./server/services/index.js
2const seo = require("./seo");
3
4module.exports = {
5 seo,
6};
Now we will simply retrieve our ContentTypes via the strapi
object which is accessible to us from the back-end part of our plugin.
1// ./server/services/seo.js
2module.exports = ({ strapi }) => ({
3 getContentTypes() {
4 return strapi.contentTypes;
5 },
6});
Invoke this service in your findSeoComponent
action within your seo
controller.
1// ./server/controllers/seo.js
2module.exports = {
3 findContentTypes(ctx) {
4 ctx.body = strapi.plugin('seo').service('seo').getContentTypes();
5},
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:
1{
2 "collectionTypes": [
3 {
4 "seo": true,
5 "uid": "api::article.article",
6 "kind": "collectionType",
7 "globalId": "Article",
8 "attributes": {
9 "title": {
10 "pluginOptions": {
11 "i18n": {
12 "localized": true
13 }
14 },
15 "type": "string",
16 "required": true
17 },
18 "slug": {
19 "pluginOptions": {
20 "i18n": {
21 "localized": true
22 }
23 },
24 "type": "uid",
25 "targetField": "title"
26 },
27 //...
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:
1// ./admin/src/utils/api.js
2import { request } from "@strapi/helper-plugin";
3import pluginId from "../pluginId";
4
5const fetchContentTypes = async () => {
6 try {
7 const data = await request(`/${pluginId}/content-types`, { method: "GET" });
8 return data;
9 } catch (error) {
10 return null;
11 }
12};
Here I will look for my pluginId
which corresponds to the name of your plugin in your ./admin/src/package.json
:
1const 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):
1/*
2 *
3 * HomePage
4 *
5 */
6
7/*
8 *
9 * HomePage
10 *
11 */
12
13import React, { memo, useState, useEffect, useRef } from 'react';
14
15import { fetchContentTypes } from '../../utils/api';
16
17import ContentTypesTable from '../../components/ContentTypesTable';
18
19import { LoadingIndicatorPage } from '@strapi/helper-plugin';
20
21import { Box } from '@strapi/design-system/Box';
22import { BaseHeaderLayout } from '@strapi/design-system/Layout';
23
24const HomePage = () => {
25 const contentTypes = useRef({});
26
27 const [isLoading, setIsLoading] = useState(true);
28
29 useEffect(async () => {
30 contentTypes.current = await fetchContentTypes(); // Here
31
32 setIsLoading(false);
33 }, []);
34
35 if (isLoading) {
36 return <LoadingIndicatorPage />;
37 }
38
39 return (
40 <>
41 <Box background="neutral100">
42 <BaseHeaderLayout
43 title="SEO"
44 subtitle="Optimize your content to be SEO friendly"
45 as="h2"
46 />
47 </Box>
48
49 <ContentTypesTable contentTypes={contentTypes.current} />
50 </>
51 );
52};
53
54export default memo(HomePage);
This page requires the following ./admin/src/components/ContentTypesTable/index.js
:
1/*
2 *
3 * HomePage
4 *
5 */
6
7import React from 'react';
8
9import { Box } from '@strapi/design-system/Box';
10import { Typography } from '@strapi/design-system/Typography';
11import { LinkButton } from '@strapi/design-system/LinkButton';
12import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
13import { Flex } from '@strapi/design-system/Flex';
14import { Table, Thead, Tbody, Tr, Td, Th } from '@strapi/design-system/Table';
15import {
16 Tabs,
17 Tab,
18 TabGroup,
19 TabPanels,
20 TabPanel,
21} from '@strapi/design-system/Tabs';
22
23const ContentTypesTable = ({ contentTypes }) => {
24 return (
25 <Box padding={8}>
26 <TabGroup label="label" id="tabs">
27 <Tabs>
28 <Tab>
29 <Typography variant="omega"> Collection Types</Typography>
30 </Tab>
31 <Tab>
32 <Typography variant="omega"> Single Types</Typography>
33 </Tab>
34 </Tabs>
35 <TabPanels>
36 <TabPanel>
37 {/* TABLE */}
38 <Table colCount={2} rowCount={contentTypes.collectionTypes.length}>
39 <Thead>
40 <Tr>
41 <Th>
42 <Typography variant="sigma">Name</Typography>
43 </Th>
44 </Tr>
45 </Thead>
46 <Tbody>
47 {contentTypes &&
48 contentTypes.collectionTypes &&
49 !_.isEmpty(contentTypes.collectionTypes) ? (
50 contentTypes.collectionTypes.map((item) => (
51 <Tr key={item.uid}>
52 <Td>
53 <Typography textColor="neutral800">
54 {item.globalId}
55 </Typography>
56 </Td>
57 <Td>
58 <Flex justifyContent="right" alignItems="right">
59 <LinkButton>Link</LinkButton>
60 </Flex>
61 </Td>
62 </Tr>
63 ))
64 ) : (
65 <Box padding={8} background="neutral0">
66 <EmptyStateLayout
67 icon={<Illo />}
68 content="You don't have any collection-types yet..."
69 action={
70 <LinkButton
71 to="/plugins/content-type-builder"
72 variant="secondary"
73 startIcon={<Plus />}
74 >
75 {Create your first collection-type}
76 </LinkButton>
77 }
78 />
79 </Box>
80 )}
81 </Tbody>
82 </Table>
83
84 {/* END TABLE */}
85 </TabPanel>
86 <TabPanel>
87 {/* TABLE */}
88 <Table colCount={2} rowCount={contentTypes.singleTypes.length}>
89 <Thead>
90 <Tr>
91 <Th>
92 <Typography variant="sigma">Name</Typography>
93 </Th>
94 </Tr>
95 </Thead>
96 <Tbody>
97 {contentTypes &&
98 contentTypes.singleTypes &&
99 !_.isEmpty(contentTypes.singleTypes) ? (
100 contentTypes.singleTypes.map((item) => (
101 <Tr key={item.uid}>
102 <Td>
103 <Typography textColor="neutral800">
104 {item.globalId}
105 </Typography>
106 </Td>
107 <Td>
108 <Flex justifyContent="right" alignItems="right">
109 <LinkButton>Link</LinkButton>
110 </Flex>
111 </Td>
112 </Tr>
113 ))
114 ) : (
115 <Box padding={8} background="neutral0">
116 <EmptyStateLayout
117 icon={<Illo />}
118 content="You don't have any single-types yet..."
119 action={
120 <LinkButton
121 to="/plugins/content-type-builder"
122 variant="secondary"
123 startIcon={<Plus />}
124 >
125 {You don't have any single-types yet...}
126 </LinkButton>
127 }
128 />
129 </Box>
130 )}
131 </Tbody>
132 </Table>
133
134 {/* END TABLE */}
135 </TabPanel>
136 </TabPanels>
137 </TabGroup>
138 </Box>
139 );
140};
141
142export 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...
./server/services/seo.js
file with the following:1'use strict';
2
3module.exports = ({ strapi }) => ({
4 getContentTypes() {
5 const contentTypes = strapi.contentTypes;
6 const keys = Object.keys(contentTypes);
7 let collectionTypes = [];
8 let singleTypes = [];
9
10 keys.forEach((name) => {
11 if (name.includes('api::')) {
12 const object = {
13 uid: contentTypes[name].uid,
14 kind: contentTypes[name].kind,
15 globalId: contentTypes[name].globalId,
16 attributes: contentTypes[name].attributes,
17 };
18 contentTypes[name].kind === 'collectionType'
19 ? collectionTypes.push(object)
20 : singleTypes.push(object);
21 }
22 });
23
24 return { collectionTypes, singleTypes } || null;
25 },
26});
If you go to your plugin page, you will see two tabs separating your collection types and your single types.
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!
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:
1register(app) {
2 app.addMenuLink({
3 to: `/plugins/${pluginId}`,
4 icon: PluginIcon,
5 intlLabel: {
6 id: `${pluginId}.plugin.name`,
7 defaultMessage: name,
8 },
9 Component: async () => {
10 const component = await import(/* webpackChunkName: "[request]" */ './pages/App');
11
12 return component;
13 },
14 permissions: [
15 // Uncomment to set the permissions of the plugin here
16 // {
17 // action: '', // the action name should be plugin::plugin-name.actionType
18 // subject: null,
19 // },
20 ],
21 });
22 app.registerPlugin({
23 id: pluginId,
24 initializer: Initializer,
25 isReady: false,
26 name,
27 });
28 },
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:
1bootstrap(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:
1async registerTrads({ locales }) {
2 const importedTrads = await Promise.all(
3 locales.map((locale) => {
4 return import(`./translations/${locale}.json`)
5 .then(({ default: data }) => {
6 return {
7 data: prefixPluginTranslations(data, pluginId),
8 locale,
9 };
10 })
11 .catch(() => {
12 return {
13 data: {},
14 locale,
15 };
16 });
17 })
18 );
19
20 return Promise.resolve(importedTrads);
21 },
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:
1// ./admin/src/components/MyCompo/index.js
2
3import React from 'react';
4
5import { Box } from '@strapi/design-system/Box';
6import { Button } from '@strapi/design-system/Button';
7import { Divider } from '@strapi/design-system/Divider';
8import { Typography } from '@strapi/design-system/Typography';
9
10import Eye from '@strapi/icons/Eye';
11
12import { useCMEditViewDataManager } from '@strapi/helper-plugin';
13
14const SeoChecker = () => {
15 const { modifiedData } = useCMEditViewDataManager();
16 console.log('Current data:', modifiedData);
17
18 return (
19 <Box
20 as="aside"
21 aria-labelledby="additional-informations"
22 background="neutral0"
23 borderColor="neutral150"
24 hasRadius
25 paddingBottom={4}
26 paddingLeft={4}
27 paddingRight={4}
28 paddingTop={6}
29 shadow="tableShadow"
30 >
31 <Box>
32 <Typography variant="sigma" textColor="neutral600" id="seo">
33 SEO Plugin
34 </Typography>
35 <Box paddingTop={2} paddingBottom={6}>
36 <Divider />
37 </Box>
38 <Box paddingTop={1}>
39 <Button
40 fullWidth
41 variant="secondary"
42 startIcon={<Eye />}
43 onClick={() =>
44 console.log('Strapi is hiring: https://strapi.io/careers')
45 }
46 >
47 One button
48 </Button>
49 </Box>
50 </Box>
51 </Box>
52 );
53};
54
55export 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:
1import MyComponent from './components/MyCompo';
2///...
3 bootstrap(app) {
4 app.injectContentManagerComponent('editView', 'right-links', {
5 name: 'MyComponent',
6 Component: MyComponent,
7 });
8 },
9///...
Et voila!
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!
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