💡Please note, that we have released an updated plugin migration guide. We suggest following the migration guide in the Strapi documentation.
Strapi v4 is here! This is a major release packing a lot of amazing new features. Unfortunately, this does mean that there will be some breaking changes when moving from v3 to v4. Since the new Plugin API is a big part of Strapi v4, we decided to put together a migration guide for plugin developers.
Here you will find all the steps you need to take to migrate your plugin with as little friction as possible. To expedite the process we've also included some codemods, or code that modifies your code automatically.
The goal of this guide is to get a v3 plugin up and running on v4 as fast as possible by resolving breaking changes. It is not an exhaustive resource for the v4 plugin API. For more information, you should consult the v4 plugin API documentation: Server API, Admin API
When possible, this guide suggests the use of codemods. To use the codemods you will need to clone this repository and run all commands provided from its root:
git clone https://github.com/strapi/codemods.git
A v3 plugin was enabled if it was installed or it was found in the plugins
directory. In v4, if a plugin is installed (in the package.json
dependencies), it is automatically enabled. However, while developing a local plugin you must explicitly enable the plugin in the ./config/plugins.js
file of the Strapi application. Disabling any plugin and adding additional config can be done here as well. Here's an example for a local plugin:
1module.exports = ({ env }) => ({
2 "my-plugin": {
3 enabled: true,
4 resolve: "./my-local-plugin",
5 config: {
6 // additional config goes here
7 },
8 },
9});
As opposed to v3 plugins, which required a specific folder structure, v4 plugins are developed using a programmatic API.
At the root of your plugin you must have the strapi-server.js
and strapi-admin.js
entry files. Otherwise, the folder structure is up to you. Here is an example:
1/plugin
2-- /admin
3---- /components
4---- /pages
5---- // etc...
6---- index.js
7-- /server
8---- /config
9---- /controllers
10---- /routes
11---- bootstrap.js
12---- // etc...
13---- index.js
14-- strapi-server.js // require('./server')
15-- strapi-admin.js // require('./admin')
Migrate with codemod
To make this update, you can use the following codemod to move files and folders into a v4 plugin structure:
1node ./migration-helpers/update-plugin-folder-structure.js <path-to-v3-plugin> [path-for-v4-plugin]
ℹ️ This codemod will create a new v4 plugin leaving your v3 plugin in place. We recommend confirming the v4 version of your plugin is working properly before deleting the v3 version.
The codemod creates the two entry files strapi-server.js
and strapi-admin.js
, organizes files and folders into /server
and /admin
directories respectively, changes models
to contentTypes
, and exports services
as functions.
ℹ️ For a more detailed explanation of what the codemod does, consult the check list below
Migrate by hand
If you prefer to make these changes yourself, you can use the checklist below to help migrate your plugin.
💡 This is only a suggested folder structure. You can organize the plugin however you want as long as everything is imported to
strapi-admin.js
andstrapi-server.js
server
directoryControllers, services, and middlewares
controllers
, services
, and middlewares
to /server
. For each directory add an index.js
file that exports all files in that folder. Make sure that each file in these directories exports a function taking {strapi}
as a parameter and returns an object. For example the controllers
directory would look like this:1// server/controllers/my-controllerA
2
3module.exports = ({ strapi }) => ({
4 doSomething(ctx) {
5 ctx.body = { message: "HelloWorld" };
6 },
7});
1// server/controllers/index.js
2
3"use strict";
4
5const myControllerA = require("./my-controllerA");
6const myControllerB = require("./my-controllerB");
7
8module.exports = {
9 myControllerA,
10 myControllerB,
11};
Bootstrap Function
/server/config/functions/bootstrap.js
to /server/bootstrap.js
and pass {strapi}
as an argument:1// server/bootstrap.js
2"use strict";
3
4module.exports = ({ strapi }) => ({
5 // bootstrap!
6});
Routes
/config/routes.json
to /server/routes/index.json
. Your routes should return an array or an object specifying admin
or content-api
routes.1// server/controllers/index.js
2
3"use strict";
4
5const myControllerA = require("./my-controllerA");
6const myControllerB = require("./my-controllerB");
7
8module.exports = {
9 myControllerA,
10 myControllerB,
11};
1// server/routes/index.js
2
3module.exports = [
4 {
5 method: "GET",
6 path: "/my-controller-a/",
7 // Camel case handler to match export in server/controllers/index.js
8 handler: "myControllerA.index",
9 config: { policies: [] },
10 },
11];
Policies
/config/policies
to /server/policies/<policyName>.js
, add an index.js
file to the directory that exports all files in the folder.Models / Content-Types
Move / rename the models
directory to /server/content-types
Move / rename each model's <modelName>.settings.json
to /server/content-types/<contentTypeName>/schema.json
schema.json
1"info": {
2 "singularName": "content-type-name", // kebab-case required
3 "pluralName": "content-type-names", // kebab-case required
4 "displayName": "Content-Type Name",
5 "name": "Content-Type Name",
6};
<model-name>.js
move / rename this file /server/content-types/<contentTypeName>/lifecycle.js
, otherwise delete the file.1// server/content-types/<content-type-name>/index.js
2
3const schema = require("./schema.json");
4const lifecycles = require("./lifecycles.js");
5
6module.exports = {
7 schema,
8 lifecycles,
9};
server/content-types
and export all content-types1// server/content-types/content-type-a/schema.json
2
3"info": {
4 "singularName": "content-type-a", // kebab-case required
5 "pluralName": "content-type-as", // kebab-case required
6 "displayName": "Content-Type A",
7 "name": "Content-Type A",
8};
1// server/content-types/index.js
2"use strict";
3
4const contentTypeA = require("./content-type-a");
5const contentTypeB = require("./content-type-b");
6
7module.exports = {
8 "content-type-a": contentTypeA,
9 "content-type-b": contentTypeB,
10};
Entry Files
strapi-server.js
and require all necessary files for your plugin. For example:1// strapi-server.js
2"use strict";
3
4const bootstrap = require("./server/bootstrap");
5const contentTypes = require("./server/contentTypes");
6const controllers = require("./server/contentTypes");
7const services = require("./server/services");
8const routes = require("./server/routes");
9
10module.exports = {
11 bootstrap,
12 contentTypes,
13 controllers,
14 services,
15 routes,
16};
strapi-admin.js
For example:1// strapi-admin.js
2"use strict";
3
4module.exports = require("./admin/src").default;
Strapi has now moved to scoped imports. All Strapi imports will need to be updated from strapi-package-name
to @strapi/package-name
.
Migrate with codemod
To update your package.json
you can use the following codemod:
1node ./migration-helpers/update-package-dependencies.js <path-to-plugin>
⚠️ This will modify your plugin source code. Before running this command, be sure you have initialized a git repo, the working tree is clean, you've pushed your v3 plugin, and you are on a new branch.
To update any files importing Strapi packages you can run:
1npx jscodeshift -t ./transforms/update-scoped-imports.js <path-to-file | path-to-folder>
⚠️ This will modify your plugin source code. Before running this command, be sure you have initialized a git repo, the working tree is clean, you've pushed your plugin to GitHub, and you are on a new branch.
Migrate by hand
If you prefer to make this change yourself, you just need to find any imports of Strapi packages and rename them to @strapi/package-name
If your plugin has models (contentTypes) you will need to make the following changes.
Models are now called ContentTypes. All getters like strapi.models
will need to be updated to strapi.contentTypes
Migrate with codemod
You can use the following codemod to replace all instances of strapi.models
with strapi.contentTypes
1npx jscodeshift -t ./transforms/change-model-getters-to-content-types.js <path-to-file | path-to-folder>
⚠️ This will modify your plugin source code. Before running this command, be sure you have initialized a git repo, the working tree is clean, you've pushed your plugin to GitHub, and you are on a new branch.
Migrate by hand
If you prefer to do this yourself, you just need to replace any instance of .models
with .contentTypes
💡 To refactor further, check out the new getters introduced in the Strapi v4 Plugin API
If your plugin has contentTypes with relations, those attributes will have to be updated manually depending on the relation. Here's an example of all possible relations between an article
and an author
1// article attributes
2"articleHasOneAuthor": {
3 "type": "relation",
4 "relation": "oneToOne",
5 "target": "api::author.author"
6},
7"articleHasAndBelongsToOneAuthor": {
8 "type": "relation",
9 "relation": "oneToOne",
10 "target": "api::author.author",
11 "inversedBy": "article"
12},
13"articleBelongsToManyAuthors": {
14 "type": "relation",
15 "relation": "oneToMany",
16 "target": "api::author.author",
17 "mappedBy": "article"
18},
19"authorHasManyArticles": {
20 "type": "relation",
21 "relation": "manyToOne",
22 "target": "api::author.author",
23 "inversedBy": "articles"
24},
25"articlesHasAndBelongsToManyAuthors": {
26 "type": "relation",
27 "relation": "manyToMany",
28 "target": "api::author.author",
29 "inversedBy": "articles"
30},
31"articleHasManyAuthors": {
32 "type": "relation",
33 "relation": "oneToMany",
34 "target": "api::author.author"
35}
36
37// author attributes
38"article": {
39 "type": "relation",
40 "relation": "manyToOne",
41 "target": "api::article.article",
42 "inversedBy": "articleBelongsToManyAuthors"
43},
44"articles": {
45 "type": "relation",
46 "relation": "manyToMany",
47 "target": "api::article.article",
48 "inversedBy": "articlesHasAndBelongsToManyAuthors"
49}
If you have any default configuration it should be exported as an object on the config
property. This object expects a default
property storing the default plugin configuration, and a validator
function that takes the config
as an argument. For example:
1// strapi-server.js
2
3module.exports = () => {
4// ...bootstrap, routes, controllers, etc...
5config: {
6 default: { optionA: true },
7 validator: (config) => {
8 if (typeof config.optionA !== 'boolean') {
9 throw new Error('optionA has to be a boolean');
10 }
11 },
12 },
13}
A v3 plugin exports its configurations as an object passed to registerPlugin(config)
, like this:
1export default (strapi) => {
2 const pluginDescription =
3 pluginPkg.strapi.description || pluginPkg.description;
4 const icon = pluginPkg.strapi.icon;
5 const name = pluginPkg.strapi.name;
6 const plugin = {
7 blockerComponent: null,
8 blockerComponentProps: {},
9 description: pluginDescription,
10 icon,
11 id: pluginId,
12 initializer: Initializer,
13 injectedComponents: [],
14 isReady: false,
15 isRequired: pluginPkg.strapi.required || false,
16 layout: null,
17 lifecycles,
18 mainComponent: App,
19 name,
20 pluginLogo,
21 preventComponentRendering: false,
22 reducers,
23 trads,
24 menu: {
25 pluginsSectionLinks: [
26 {
27 destination: `/plugins/${pluginId}`,
28 icon,
29 label: {
30 id: `${pluginId}.plugin.name`,
31 defaultMessage: "My Plugin",
32 },
33 name,
34 permissions: pluginPermissions.main,
35 },
36 ],
37 },
38 };
39
40 return strapi.registerPlugin(plugin);
41};
To migrate this to v4 we will need to export a function that calls the register()
lifecycle function, passing the current strapi app as an argument:
1export default {
2 register(app) {
3 // executes as soon as the plugin is loaded
4 },
5};
Here we can go ahead and register our plugin by grabbing the name
and id
keys from the old configuration object:
1import pluginId from './pluginId';
2
3const pluginDescription = pluginPkg.strapi.description || pluginPkg.description;
4const name = pluginPkg.strapi.name;
5
6export default {
7 register(app) {
8 app.registerPlugin({
9 id: pluginId
10 name,
11 })
12 }
13 }
To add a link to your plugin in the Strapi Admin, use the addMenuLink()
function called in the register
lifecycle. The menu
key from the v3 config object can be passed to app.addMenuLink()
with the following properties changed:
destination
⇒ to
label
⇒ intlLabel
icon
is no longer a string, it's now a React component. You can create it in a separate file like this:1import React from "react";
2import { Icon } from "@strapi/parts/Icon";
3import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4
5const PluginIcon = () => (
6 <Icon as={() => <FontAwesomeIcon icon="paint-brush" />} width="16px" />
7);
8
9export default PluginIcon;
In v3 the component would be specified on the mainComponent
key, in v4 the component is passed as a dynamic import to the app.addMenuLink()
function.
1import pluginId from './pluginId';
2import pluginPermissions from './permissions';
3import PluginIcon from './PluginIcon'
4
5const pluginDescription = pluginPkg.strapi.description || pluginPkg.description;
6const name = pluginPkg.strapi.name;
7
8export default {
9 register(app) {
10 app.addMenuLink({
11 to: `/plugins/${pluginId}`,
12 icon: PluginIcon,
13 intlLabel: {
14 id: `${pluginId}.plugin.name`,
15 defaultMessage: 'My Plugin',
16 },
17 permissions: pluginPermissions.main,
18 Component: async () => {
19 const component = await import(/* webpackChunkName: "my-plugin-page" */ './pages/PluginPage');
20
21 return component;
22 },
23 });
24
25 app.registerPlugin({
26 description: pluginDescription,
27 icon,
28 id: pluginId
29 name
30 });
31 }
32}
At this point a basic plugin with a single view should be migrated to v4. However, it is likely that you will want to customize your plugin further. Depending on the needs of your plugin you will have to look into the different API's available.
In addition to the register()
lifecycle function, which is executed as soon as the plugin is loaded, there is also the bootstrap()
lifecycle function which executes after all plugins are loaded.
To add a settings link or section, use redux reducers, hook into other plugins, and modify the UI with injection zones, consult this table for all available API's and their associated lifecycle functions.
The plugin interface can also export an asynchronous registerTrads()
function for registering translation files. You can use the following function:
1import { prefixPluginTranslations } from "@strapi/helper-plugin";
2
3export default {
4 register(app) {
5 // register code...
6 },
7 bootstrap(app) {
8 // bootstrap code...
9 },
10 async registerTrads({ locales }) {
11 const importedTrads = await Promise.all(
12 locales.map((locale) => {
13 return import(
14 /* webpackChunkName: "[pluginId]-[request]" */ `./translations/${locale}.json`
15 )
16 .then(({ default: data }) => {
17 return {
18 data: prefixPluginTranslations(data, pluginId),
19 locale,
20 };
21 })
22 .catch(() => {
23 return {
24 data: {},
25 locale,
26 };
27 });
28 })
29 );
30
31 return Promise.resolve(importedTrads);
32 },
33};
Hopefully this guide has helped you migrate your plugin from Strapi v3 to Strapi v4. Strapi Market is coming soon and we are looking forward to many plugins developed by the community. For more information about Strapi Market, read the blog post. If you are ready to submit your plugin all you need to do is fill out this form.
If you have any issues with the codemods or would like to contribute to the project please create an issue or open a pull request.
Mark is a Software developer from the United States living in Paris. He's also an amazing classical guitar player and member of the StrapiBand!