Strapi Plugin Development
A Strapi plugin is a self-contained module with its own content types, controllers, services, routes, and UI components for the admin panel. Official plugins (users-permissions, upload, email, i18n) are built using the same pattern. A custom plugin is a way to package reusable functionality.
Plugin Structure
src/plugins/my-plugin/
├── admin/
│ └── src/
│ ├── index.tsx # Admin panel registration
│ ├── pages/
│ │ └── HomePage/
│ │ └── index.tsx
│ └── components/
├── server/
│ ├── index.ts # Server registration
│ ├── content-types/ # Plugin content types
│ │ └── log-entry/
│ │ └── schema.json
│ ├── controllers/
│ │ └── my-controller.ts
│ ├── services/
│ │ └── my-service.ts
│ ├── routes/
│ │ └── my-routes.ts
│ └── middlewares/
├── package.json
└── strapi-server.js # Entry point for Strapi
Creating a Plugin via CLI
# In root of Strapi project
npx @strapi/sdk-plugin@latest generate
# Enter: plugin name, path, language (TypeScript)
Server Part (server/index.ts)
// server/index.ts
import controllers from './controllers'
import services from './services'
import routes from './routes'
import contentTypes from './content-types'
export default {
register({ strapi }) {
// Registration on startup
strapi.customFields.register({
name: 'color',
plugin: 'my-plugin',
type: 'string',
})
},
bootstrap({ strapi }) {
// Executed after Strapi startup
strapi.log.info('My Plugin bootstrapped')
},
contentTypes,
controllers,
services,
routes,
}
Plugin Service
// server/services/analytics.ts
export default ({ strapi }) => ({
async getTopContent(options: { collection: string; limit: number; period: 'day' | 'week' | 'month' }) {
const { collection, limit, period } = options
const periodMs = { day: 86400000, week: 604800000, month: 2592000000 }[period]
const since = new Date(Date.now() - periodMs).toISOString()
// Get entries with view counts
const items = await strapi.entityService.findMany(`api::${collection}.${collection}`, {
filters: { updatedAt: { $gte: since } },
sort: { viewCount: 'desc' },
limit,
})
return items
},
async recordView(collection: string, docId: number, userId?: number) {
await strapi.entityService.create('plugin::my-plugin.view-log', {
data: {
collection,
docId: String(docId),
userId: userId || null,
ip: null,
timestamp: new Date().toISOString(),
},
})
// Update counter
const doc = await strapi.entityService.findOne(`api::${collection}.${collection}`, docId)
if (doc) {
await strapi.entityService.update(`api::${collection}.${collection}`, docId, {
data: { viewCount: ((doc as any).viewCount || 0) + 1 },
})
}
},
})
Plugin Controller
// server/controllers/analytics.ts
export default {
async getStats(ctx) {
const { collection = 'article', period = 'week', limit = 10 } = ctx.query
const stats = await strapi
.plugin('my-plugin')
.service('analytics')
.getTopContent({ collection, period, limit: Number(limit) })
ctx.body = { data: stats }
},
async recordView(ctx) {
const { collection, id } = ctx.params
await strapi
.plugin('my-plugin')
.service('analytics')
.recordView(collection, Number(id), ctx.state.user?.id)
ctx.body = { success: true }
},
}
Plugin Routes
// server/routes/index.ts
export default [
{
method: 'GET',
path: '/analytics/stats',
handler: 'analytics.getStats',
config: {
policies: ['admin::isAuthenticatedAdmin'],
},
},
{
method: 'POST',
path: '/analytics/:collection/:id/view',
handler: 'analytics.recordView',
config: {
auth: false,
middlewares: ['plugin::my-plugin.rate-limit'],
},
},
]
Admin UI Component
// admin/src/index.tsx
import { prefixPluginTranslations } from '@strapi/helper-plugin'
import pluginId from './pluginId'
import HomePage from './pages/HomePage'
import { PluginIcon } from './components/PluginIcon'
export default {
register(app: any) {
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon: PluginIcon,
intlLabel: { id: `${pluginId}.plugin.name`, defaultMessage: 'Analytics' },
Component: async () => {
const { default: component } = await import('./pages/HomePage')
return component
},
})
app.registerPlugin({
id: pluginId,
name: 'My Analytics Plugin',
})
},
bootstrap(app: any) {
// Add panel to edit view of specific content type
app.injectContentManagerComponent('editView', 'right-links', {
name: 'ViewCountWidget',
Component: async () => {
const { ViewCountWidget } = await import('./components/ViewCountWidget')
return ViewCountWidget
},
})
},
}
// admin/src/pages/HomePage/index.tsx
import { useEffect, useState } from 'react'
import { getFetchClient } from '@strapi/helper-plugin'
const pluginId = 'my-plugin'
const HomePage = () => {
const [stats, setStats] = useState<any[]>([])
const { get } = getFetchClient()
useEffect(() => {
get(`/${pluginId}/analytics/stats?period=week&limit=10`)
.then(res => setStats(res.data.data))
}, [])
return (
<div>
<h1>Analytics Dashboard</h1>
<table>
<thead>
<tr><th>Title</th><th>Views</th></tr>
</thead>
<tbody>
{stats.map((item, i) => (
<tr key={i}>
<td>{item.title}</td>
<td>{item.viewCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default HomePage
Plugin Configuration
// config/plugins.js
module.exports = {
'my-plugin': {
enabled: true,
config: {
trackingEnabled: true,
excludeAdminViews: true,
},
},
}
// Access configuration in plugin
const config = strapi.config.get('plugin.my-plugin')
Publishing
// plugin's package.json
{
"name": "strapi-plugin-my-analytics",
"version": "1.0.0",
"strapi": {
"kind": "plugin",
"name": "my-plugin",
"displayName": "My Analytics"
},
"peerDependencies": {
"@strapi/strapi": ">=4.0.0"
}
}
Timeline
Developing a full plugin with server, content type, and admin UI — 5–8 days.







