Skip to main content

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​

PhaseMethodUse case
Registerregister()Register Document Service middleware, extend GraphQL schema, add middleware
Bootstrapbootstrap()Seed data, set up cron jobs, register event listeners
Destroydestroy()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​

  1. 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.

  1. Build the plugin:
npm run build && npm run verify
  1. Publish:
npm publish
  1. Users install it:
npm install strapi-plugin-bookmarks

Common pitfalls​

PitfallProblemFix
Forgetting to export content typesStrapi doesn't create the database tablesExport from server/src/content-types/index.ts
Plugin routes not working404 on all plugin endpointsRoutes are auto-prefixed with the plugin name
Admin page not loadingBlank page in adminEnsure register() calls app.registerPlugin()
Missing translationsUI shows translation keysExport registerTrads with locale files
Accessing wrong servicestrapi.service() returns undefinedUse strapi.plugin('name').service('name')

See also​