Plugin Development
Plugins are Strapi's primary extension mechanism. They bundle backend logic (content types, services, controllers) and admin UI (React components, sidebar links, settings pages) into reusable packages.
Plugin architecture
Scaffolding a plugin
Use the CLI generator:
npx @strapi/sdk-plugin init my-plugin
The Plugin SDK can create a plugin outside a Strapi project, build it for npm, and link it to a project with yalc
while you develop.
This creates the following structure:
src/plugins/my-plugin/
├── admin/
│ └── src/
│ ├── index.ts # Admin entry file
│ ├── pages/ # Admin views
│ └── components/ # Shared components
├── server/
│ └── src/
│ ├── index.ts # Server entry file
│ ├── content-types/ # Plugin content types
│ ├── controllers/ # Plugin controllers
│ ├── routes/ # Plugin routes
│ ├── services/ # Plugin services
│ └── policies/ # Plugin policies
├── package.json
└── strapi-admin.js # Admin build entry
Server API
Entry file
// server/src/index.ts
import contentTypes from './content-types';
import controllers from './controllers';
import routes from './routes';
import services from './services';
import policies from './policies';
import middlewares from './middlewares';
import config from './config';
export default {
config,
register({ strapi }) {
// Registration logic (runs before bootstrap)
},
bootstrap({ strapi }) {
// Bootstrap logic (runs after all plugins registered)
},
destroy({ strapi }) {
// Cleanup on shutdown
},
contentTypes,
controllers,
routes,
services,
policies,
middlewares,
};
Content types
File: server/src/content-types/bookmark/schema.json
{
"kind": "collectionType",
"collectionName": "bookmarks",
"info": {
"singularName": "bookmark",
"pluralName": "bookmarks",
"displayName": "Bookmark"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"title": {
"type": "string",
"required": true
},
"url": {
"type": "string",
"required": true
},
"description": {
"type": "text"
},
"owner": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}
}
}
// server/src/content-types/index.ts
import bookmarkSchema from './bookmark/schema.json';
export default {
bookmark: {
schema: bookmarkSchema,
},
};
Controllers
// server/src/controllers/bookmark.ts
import type { Core } from '@strapi/strapi';
const bookmarkController = ({ strapi }: { strapi: Core.Strapi }) => ({
async find(ctx) {
const user = ctx.state.user;
const bookmarks = await strapi
.plugin('my-plugin')
.service('bookmark')
.findByUser(user.id);
ctx.body = { data: bookmarks };
},
async create(ctx) {
const user = ctx.state.user;
const { title, url, description } = ctx.request.body;
const bookmark = await strapi
.plugin('my-plugin')
.service('bookmark')
.create({ title, url, description, owner: user.id });
ctx.body = { data: bookmark };
},
async delete(ctx) {
const { id } = ctx.params;
const user = ctx.state.user;
const deleted = await strapi
.plugin('my-plugin')
.service('bookmark')
.deleteIfOwner(id, user.id);
if (!deleted) {
return ctx.forbidden('You can only delete your own bookmarks');
}
ctx.body = { data: deleted };
},
});
export default bookmarkController;
Services
// server/src/services/bookmark.ts
import type { Core } from '@strapi/strapi';
const bookmarkService = ({ strapi }: { strapi: Core.Strapi }) => ({
async findByUser(userId: string) {
return strapi.documents('plugin::my-plugin.bookmark').findMany({
filters: { owner: { id: userId } },
sort: { createdAt: 'desc' },
});
},
async create(data: { title: string; url: string; description?: string; owner: string }) {
return strapi.documents('plugin::my-plugin.bookmark').create({
data,
});
},
async deleteIfOwner(bookmarkId: string, userId: string) {
const bookmark = await strapi.documents('plugin::my-plugin.bookmark').findOne({
documentId: bookmarkId,
populate: ['owner'],
});
if (!bookmark || bookmark.owner?.id !== userId) {
return null;
}
return strapi.documents('plugin::my-plugin.bookmark').delete({
documentId: bookmarkId,
});
},
});
export default bookmarkService;
Routes
// server/src/routes/index.ts
export default {
'content-api': {
type: 'content-api',
routes: [
{
method: 'GET',
path: '/bookmarks',
handler: 'bookmark.find',
config: {
policies: [],
},
},
{
method: 'POST',
path: '/bookmarks',
handler: 'bookmark.create',
config: {
policies: [],
},
},
{
method: 'DELETE',
path: '/bookmarks/:id',
handler: 'bookmark.delete',
config: {
policies: [],
},
},
],
},
admin: {
type: 'admin',
routes: [
{
method: 'GET',
path: '/bookmarks',
handler: 'bookmark.find',
config: {
policies: ['admin::isAuthenticatedAdmin'],
},
},
],
},
};
The named router format makes the route surface explicit. Content API routes and admin routes are both prefixed with
/<plugin-name>/, so the public endpoints above become /my-plugin/bookmarks.
If the plugin only exposes admin-panel endpoints, keep them in the admin router. Use content-api only for endpoints
intended to be called by frontends through Strapi's Content API authentication.
Admin Panel API
Entry file
In Strapi 5 the legacy @strapi/helper-plugin package has been removed. Import admin panel utilities from
@strapi/strapi/admin (or @strapi/design-system for UI primitives). The prefixPluginTranslations helper is no
longer exported; define it inline in your plugin.
// admin/src/index.ts
import pluginId from './pluginId';
import BookmarkIcon from './components/BookmarkIcon';
// prefixPluginTranslations was removed from @strapi/helper-plugin; inline the util.
type Translations = Record<string, string>;
const prefixPluginTranslations = (trad: Translations, pluginId: string): Translations => {
if (!pluginId) {
throw new TypeError("pluginId can't be empty");
}
return Object.keys(trad).reduce((acc, current) => {
acc[`${pluginId}.${current}`] = trad[current];
return acc;
}, {} as Translations);
};
export default {
register(app) {
// Add a sidebar link
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon: BookmarkIcon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Bookmarks',
},
Component: () => import('./pages/App'),
permissions: [],
});
// Register the plugin
app.registerPlugin({
id: pluginId,
name: 'Bookmarks',
});
},
bootstrap(app) {
// Add settings link
app.addSettingsLink('global', {
intlLabel: {
id: `${pluginId}.settings.title`,
defaultMessage: 'Bookmark Settings',
},
id: `${pluginId}-settings`,
to: `/settings/${pluginId}`,
Component: () => import('./pages/Settings'),
permissions: [],
});
},
async registerTrads({ locales }) {
const importedTrads = await Promise.all(
locales.map((locale) => {
return import(`./translations/${locale}.json`)
.then(({ default: data }) => ({
data: prefixPluginTranslations(data, pluginId),
locale,
}))
.catch(() => ({ data: {}, locale }));
})
);
return importedTrads;
},
};
Plugin page component
// admin/src/pages/App.tsx
import React, { useEffect, useState } from 'react';
import { Box, Typography, Table, Thead, Tbody, Tr, Th, Td, Button } from '@strapi/design-system';
import { useFetchClient } from '@strapi/strapi/admin';
import pluginId from '../pluginId';
const App = () => {
const { get, del } = useFetchClient();
const [bookmarks, setBookmarks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchBookmarks = async () => {
try {
const { data } = await get(`/${pluginId}/bookmarks`);
setBookmarks(data.data || []);
} catch (error) {
console.error('Failed to fetch bookmarks:', error);
} finally {
setLoading(false);
}
};
fetchBookmarks();
}, []);
const handleDelete = async (id: string) => {
try {
await del(`/${pluginId}/bookmarks/${id}`);
setBookmarks((prev) => prev.filter((b) => b.id !== id));
} catch (error) {
console.error('Delete failed:', error);
}
};
if (loading) return <Typography>Loading...</Typography>;
return (
<Box padding={8}>
<Typography variant="alpha">My Bookmarks</Typography>
<Table colCount={4} rowCount={bookmarks.length}>
<Thead>
<Tr>
<Th><Typography variant="sigma">Title</Typography></Th>
<Th><Typography variant="sigma">URL</Typography></Th>
<Th><Typography variant="sigma">Created</Typography></Th>
<Th><Typography variant="sigma">Actions</Typography></Th>
</Tr>
</Thead>
<Tbody>
{bookmarks.map((bookmark) => (
<Tr key={bookmark.id}>
<Td><Typography>{bookmark.title}</Typography></Td>
<Td><a href={bookmark.url} target="_blank" rel="noopener noreferrer">{bookmark.url}</a></Td>
<Td><Typography>{new Date(bookmark.createdAt).toLocaleDateString()}</Typography></Td>
<Td>
<Button variant="danger-light" onClick={() => handleDelete(bookmark.id)}>
Delete
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
);
};
export default App;
Plugin configuration
Allow users to configure your plugin:
// server/src/index.ts
export default {
register({ strapi }) {
// Access plugin config
const config = strapi.config.get('plugin.my-plugin');
strapi.log.info(`Plugin configured with: ${JSON.stringify(config)}`);
},
};
Users configure it in their project:
// config/plugins.js
module.exports = {
'my-plugin': {
enabled: true,
resolve: './src/plugins/my-plugin', // only for local plugins; omit for npm-installed plugins
config: {
maxBookmarksPerUser: 100,
enablePublicBookmarks: false,
},
},
};
Access config in services:
async create(data) {
const config = strapi.config.get('plugin.my-plugin');
const userBookmarks = await this.findByUser(data.owner);
if (userBookmarks.length >= config.maxBookmarksPerUser) {
throw new Error('Bookmark limit reached');
}
return strapi.documents('plugin::my-plugin.bookmark').create({ data });
},
Plugin lifecycle
| Phase | Method | Use case |
|---|---|---|
| Register | register() | Register Document Service middleware, extend GraphQL schema, add middleware |
| Bootstrap | bootstrap() | Seed data, set up cron jobs, register event listeners |
| Destroy | destroy() | Clean up connections, stop timers |
export default {
register({ strapi }) {
// Extend the Document Service
strapi.documents.use(async (context, next) => {
if (context.uid === 'plugin::my-plugin.bookmark') {
// Custom logic for bookmark operations
}
return next();
});
},
bootstrap({ strapi }) {
// Seed default bookmarks for new users
strapi.db.lifecycles.subscribe({
models: ['plugin::users-permissions.user'],
async afterCreate(event) {
const { result } = event;
await strapi.documents('plugin::my-plugin.bookmark').create({
data: {
title: 'Getting Started',
url: 'https://docs.strapi.io',
owner: result.id,
},
});
},
});
},
destroy({ strapi }) {
strapi.log.info('My plugin shutting down');
},
};
Publishing to npm
- Update
package.json:
{
"name": "strapi-plugin-bookmarks",
"version": "1.0.0",
"description": "Bookmark management for Strapi",
"strapi": {
"name": "bookmarks",
"displayName": "Bookmarks",
"description": "Save and manage bookmarks",
"kind": "plugin"
},
"main": "./dist/server/index.js",
"types": "./dist/server/index.d.ts",
"scripts": {
"build": "strapi-plugin build",
"watch": "strapi-plugin watch",
"watch:link": "strapi-plugin watch:link",
"verify": "strapi-plugin verify"
},
"exports": {
"./package.json": "./package.json",
"./strapi-admin": {
"types": "./dist/admin/src/index.d.ts",
"source": "./admin/src/index.ts",
"import": "./dist/admin/index.mjs",
"require": "./dist/admin/index.js",
"default": "./dist/admin/index.js"
},
"./strapi-server": {
"types": "./dist/server/src/index.d.ts",
"source": "./server/src/index.ts",
"import": "./dist/server/index.mjs",
"require": "./dist/server/index.js",
"default": "./dist/server/index.js"
}
},
"files": ["dist"],
"keywords": ["strapi", "plugin", "bookmarks"],
"license": "MIT"
}
main must point to a compiled JavaScript file, not the TypeScript source. The @strapi/sdk-plugin build
step generates the dist/ directory used above.
- Build the plugin:
npm run build && npm run verify
- Publish:
npm publish
- Users install it:
npm install strapi-plugin-bookmarks
Common pitfalls
| Pitfall | Problem | Fix |
|---|---|---|
| Forgetting to export content types | Strapi doesn't create the database tables | Export from server/src/content-types/index.ts |
| Plugin routes not working | 404 on all plugin endpoints | Routes are auto-prefixed with the plugin name |
| Admin page not loading | Blank page in admin | Ensure register() calls app.registerPlugin() |
| Missing translations | UI shows translation keys | Export registerTrads with locale files |
| Accessing wrong service | strapi.service() returns undefined | Use strapi.plugin('name').service('name') |
See also
- Custom Controllers and Services - the patterns plugins use internally
- Admin Panel Customization - extending the admin without a full plugin
- Middleware and Policies - adding middleware from plugins
- Lifecycle Hooks - plugin lifecycle events