Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
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.
1
2
3
4
5
6
7
8
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:
1
2
3
4
5
6
7
8
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.
Before going any further, I must tell you about the architecture of your plugin, and to do this, here is a nice tree:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
├── 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.
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
2
3
4
5
6
7
8
9
10
11
12
13
// ./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.
./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
2
3
4
5
6
// ./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.
1
2
3
4
5
6
// ./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.
1
2
3
4
5
6
// ./server/services/seo.js
module.exports = ({ strapi }) => ({
getContentTypes() {
return strapi.contentTypes;
},
});
Invoke this service in your findSeoComponent
action within your seo
controller.
1
2
3
4
5
// ./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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"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:
1
2
3
4
5
6
7
8
9
10
11
12
// ./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
:
1
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):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
*
* 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
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/*
*
* 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...
./server/services/seo.js
file with the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
'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.
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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:
1
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// ./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:
1
2
3
4
5
6
7
8
9
import MyComponent from './components/MyCompo';
///...
bootstrap(app) {
app.injectContentManagerComponent('editView', 'right-links', {
name: 'MyComponent',
Component: MyComponent,
});
},
///...
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