Introduction
Strapi is a headless CMS with a plugin system that extends both the API and the admin panel. Plugins let you add endpoints, wire in third-party services, and put custom screens in front of your editors, all without forking core.
This guide walks through building a custom plugin on Strapi 5: generating the scaffold, shipping a working endpoint backed by a service, adding an admin panel page, and fixing the errors that usually show up on the first run.
A note on versions
This guide targets Strapi 5.x. That matters more than it sounds, because most Strapi plugin tutorials still circulating were written for Strapi 3, and every command in them now fails. If you have been following an older guide, these are the ones that no longer exist:
strapi newand the globally installed CLI. Project creation runs throughcreate-strapi.strapi generate:plugin. Plugin scaffolding moved to the Plugin SDK.routes.json. Routes are JavaScript or TypeScript modules.- Controllers exported as plain objects. Strapi 4 introduced the factory signature and Strapi 5 kept it.
Strapi 5 also replaced the Entity Service API with the Document Service API,
reached through strapi.documents(). The service example below uses it.
Prerequisites
- Node.js on an Active LTS or Maintenance LTS release. Strapi 5 supports v22, v24, and v26. Odd-numbered Node releases are not supported.
- Working knowledge of JavaScript and Node.js.
- Familiarity with the Strapi project structure. If you are wiring Strapi to a frontend, our guide on setting up Next.js with Strapi covers that side.
Do you actually need a plugin?
A plugin is not always the right tool, and reaching for one too early adds a build step and a release process you may not need. Pick based on what you are changing:
- Adding an endpoint to one content type. Use a custom route and controller in
src/api/. No plugin needed. - Changing what happens on create or update. Use lifecycle hooks or Document Service middleware.
- Reusing logic across several projects, adding admin panel screens, or distributing to a team. This is what plugins are for.
If you land in the third bucket, keep reading.
Step 1: Create a Strapi 5 project
Skip this if you already have a project. Otherwise, scaffold one:
npx create-strapi@latest my-strapi-project
The installer asks about TypeScript, a database, and whether to seed an example
app. The older create-strapi-app command still works and gives the
same result. Then start the dev server:
cd my-strapi-project
npm run develop
Step 2: Generate the plugin
Strapi 5 scaffolds plugins with the Plugin SDK rather than the old generator. From your project root:
npx @strapi/sdk-plugin init src/plugins/my-plugin
The SDK prompts for a package name, a display name, and whether you want TypeScript, an admin panel part, and a server part. Answer yes to both parts if you plan to follow every step here.
Step 3: Understand the structure
The generated plugin splits cleanly into two halves:
src/plugins/my-plugin
├── admin
│ └── src # React code for the admin panel
├── server
│ └── src
│ ├── config
│ ├── content-types
│ ├── controllers
│ ├── policies
│ ├── routes
│ ├── services
│ └── index.js # server entry point
└── package.json
The server half runs in Node and owns your endpoints, services, and
content types. The admin half is a React application bundled into the
Strapi admin panel. They are independent, and a plugin can ship only one of them.
Step 4: Add a route
Routes are modules, not JSON. Open server/src/routes/index.js. The
named router format is worth using from the start, because it forces you to be
explicit about whether an endpoint is public API or admin-only:
module.exports = {
'content-api': {
type: 'content-api',
routes: [
{
method: 'GET',
path: '/hello',
handler: 'hello.index',
config: {
auth: false,
policies: [],
},
},
],
},
};
Two things to note. handler points at a controller and one of its
methods, in controller.method form. And auth: false
makes this route public, which is what you want for a hello-world test. Leave it
out on anything real and grant access deliberately instead.
Step 5: Add a controller and a service
Controllers in Strapi 5 are factory functions that receive strapi,
not plain objects. Create server/src/controllers/hello.js:
module.exports = ({ strapi }) => ({
async index(ctx) {
ctx.body = await strapi
.plugin('my-plugin')
.service('hello')
.getMessage();
},
});
Keep the controller thin and put the real work in a service. Services use the same
factory signature. Create server/src/services/hello.js:
module.exports = ({ strapi }) => ({
getMessage() {
return { message: 'Hello, World!' };
},
});
This separation is the part most tutorials skip, and it is the part that pays off. Services are callable from anywhere in Strapi, including other plugins, lifecycle hooks, and cron jobs. Logic buried in a controller is reachable only over HTTP.
Both files need to be exported from their respective index.js barrel
files, which the SDK generates for you. Add your new files there:
// server/src/controllers/index.js
const hello = require('./hello');
module.exports = {
hello,
};
When a service talks to a content type, reach for the Document Service API:
module.exports = ({ strapi }) => ({
async listMessages(params = {}) {
return strapi
.documents('plugin::my-plugin.message')
.findMany(params);
},
});
Step 6: Enable the plugin
A local plugin does not appear until you register it. There is no Install button
for local plugins, that flow belongs to the Marketplace. Open
config/plugins.js at your project root:
module.exports = () => ({
'my-plugin': {
enabled: true,
resolve: './src/plugins/my-plugin',
},
});
The key must match your plugin name exactly. Restart the dev server afterwards, because plugin registration happens at boot.
Step 7: Test the endpoint
Plugin content-api routes are namespaced under the plugin name and sit behind the
/api prefix. So a route declared as /hello in a plugin
named my-plugin is served at:
GET http://localhost:1337/api/my-plugin/hello
Call it with curl or Postman and you should get back:
{ "message": "Hello, World!" }
Getting the prefix wrong is the single most common reason a new plugin route
returns 404. It is not /my-plugin/hello.
Step 8: Add an admin panel page
The server half is only half the plugin. To give editors a screen, register a menu
link in admin/src/index.js:
import { Puzzle } from '@strapi/icons';
export default {
register(app) {
app.addMenuLink({
to: '/plugins/my-plugin',
icon: Puzzle,
intlLabel: {
id: 'my-plugin.menu.label',
defaultMessage: 'My Plugin',
},
Component: () => import('./pages/HomePage'),
permissions: [],
});
app.registerPlugin({
id: 'my-plugin',
name: 'My Plugin',
});
},
};
Component is a dynamic import, so the page is code-split and does not
weigh down the admin bundle until someone opens it. Leaving
permissions empty shows the link to every authenticated admin user,
so populate it once you know who should see the screen.
Keeping it local or publishing it
A plugin in src/plugins/ is local to one project, which is the right
default while you are still shaping the API. When you want it in a second project,
the Plugin SDK builds and publishes it:
npm run build
npm run verify
npm publish
Once published, installing it in another project is a normal dependency install,
and you drop the resolve line from config/plugins.js
because Strapi finds it in node_modules. For plugins you do not want
on the public registry, a private registry or a git dependency both work.
Common errors and how to fix them
The plugin does not appear at all
Almost always a missing or misspelled entry in config/plugins.js. The
config key has to match the plugin name, and the resolve path is
relative to the project root. Restart the server after any change there.
404 on the route
Check the prefix first, as covered in step 7. If the path is right, confirm the
handler string matches an exported controller. A handler pointing at
a controller missing from controllers/index.js fails quietly.
403 Forbidden
Content-api routes run through the Users and Permissions plugin. Either set
auth: false on the route, or grant the endpoint to the relevant role
under Settings, Users and Permissions plugin, Roles. Newly added plugin routes are
not granted to the Public role automatically.
Admin changes are not showing up
The admin panel is a separate build. Rebuild it, and clear the cache if a stale bundle persists:
npm run build
npm run develop
Controller is not a function
This is the Strapi 3 controller shape leaking in from an old tutorial. Controllers
must export a factory, ({ strapi }) => ({ ... }), not a plain
object of methods.
Frequently asked questions
What is the difference between a plugin and a custom API in Strapi?
A custom API in src/api/ belongs to one project and one content type.
A plugin is packaged, reusable across projects, and can extend the admin panel.
Build a plugin when you need reuse or admin UI, and a custom API otherwise.
Do Strapi 3 or Strapi 4 plugins work on Strapi 5?
Strapi 3 plugins do not. Strapi 4 plugins usually need changes, mainly moving from the Entity Service API to the Document Service API and updating the admin panel imports. The server-side factory signature carried over unchanged.
Where do plugin content-api routes live?
Under /api/<plugin-name>/<path>. The plugin name is
prepended automatically unless you override prefix on the router
object.
Can a plugin add its own content types?
Yes. Define them in server/src/content-types/ and they are addressed
as plugin::my-plugin.name through the Document Service API.
Does a custom plugin survive a Strapi upgrade?
Within a major version, generally yes. Across a major version, budget for changes, since the admin panel APIs and data layer are where breaking changes land.
Building something more involved?
A hello-world plugin takes an afternoon. Plugins that carry real weight, custom workflow engines, third-party integrations with retry and audit requirements, or admin panel tooling that non-technical teams depend on daily, take considerably longer and age badly if the architecture is wrong at the start.
We build and run Strapi in production, including the CMS behind this site. If you are weighing a custom Strapi build or inheriting one that needs work, our application development team can help. Get in touch and we'll talk specifics.
Where to go next
You now have a plugin with a public endpoint, a service holding the logic, and a page in the admin panel. From here, the useful next steps are adding content types for persistence, adding policies to control access per route, and writing tests against your services rather than your endpoints.
The official Strapi plugin development documentation is the reference to keep open, and it is versioned, so check that you are reading the Strapi 5 pages.



