Strapi Custom Plugins — Plugin Structure, Server vs Admin Code
In this tutorial, you will learn how to build custom Strapi plugins — understanding the plugin file structure, writing server-side code for custom API endpoints and services, and creating admin-side React components for custom admin panel pages.
What You'll Learn
- The complete file structure of a Strapi plugin
- How to create a plugin using the Strapi CLI generator
- How to write server-side code (controllers, services, routes, content types)
- How to write admin-side code (React components, pages)
- How to register and enable your plugin
- How to distribute plugins via npm
Why It Matters
The plugin marketplace covers common needs, but every project has unique requirements. A custom plugin encapsulates project-specific functionality into a reusable, maintainable package. Instead of scattering custom code across lifecycle hooks and middleware, you build a plugin that can be shared across projects or even published for others.
Real-World Use
A publishing platform needs a custom "Content Scheduler" feature that lets editors schedule content publication with recurring patterns (every Monday, first of month) and sends Slack notifications when content publishes. This is too specific for the marketplace. The developer builds a custom plugin with admin panel interface for scheduling, server-side cron jobs for publishing, and Slack integration for notifications.
Learning Path
flowchart LR A["Plugin Ecosystem"] --> B["Custom Plugins
-- You are here"]:::current B --> C["Admin Customization"] C --> D["Email & Notifications"] D --> E["Internationalization"] classDef current fill:#4945ff,color:#fff,stroke-width:2px
Generating a Plugin
The easiest way to start is with Strapi's plugin generator:
# Generate a new plugin
npm run strapi generate:plugin content-scheduler
# This creates:
# src/plugins/content-scheduler/
# admin/
# src/
# index.js -- Plugin entry point
# pluginId.js -- Plugin identifier
# components/ -- React components
# pages/ -- Admin pages
# utils/ -- Utilities
# translations/ -- i18n translations
# server/
# config/ -- Plugin configuration
# controllers/ -- API controllers
# services/ -- Business logic
# content-types/ -- Content types
# middlewares/ -- Middleware
# policies/ -- Access policies
# bootstrap.js -- Startup logic
# register.js -- Registration logic
# destroy.js -- Cleanup logic
# strapi-admin.js -- Admin registration
# strapi-server.js -- Server registration
# package.json
After generating, enable the plugin in config/plugins.js:
// config/plugins.js
module.exports = {
"content-scheduler": {
enabled: true,
config: {
// Plugin-specific settings
},
},
};
Plugin File Structure Explained
The server side of a plugin follows the same patterns as Strapi's core API:
// server/register.js — Runs during plugin registration
module.exports = ({ strapi }) => {
// Extend Strapi's functionality here
// Register custom content types, fields, or middleware
};
// server/bootstrap.js — Runs on Strapi startup
module.exports = async ({ strapi }) => {
// Initialize services, set up cron jobs
const myService = strapi.plugin("content-scheduler").service("myService");
await myService.initialize();
};
// server/destroy.js — Runs on Strapi shutdown
module.exports = ({ strapi }) => {
// Cleanup connections, close resources
};
// server/config/index.js — Plugin configuration schema
module.exports = {
default: ({ env }) => ({
defaultSchedule: "0 9 * * 1", // Every Monday at 9 AM
slackWebhook: env("SLACK_WEBHOOK_URL", ""),
}),
validator: (config) => {
if (config.defaultSchedule && !/^[^\s]+\s[^\s]+\s[^\s]+\s[^\s]+\s[^\s]+$/.test(config.defaultSchedule)) {
throw new Error("Invalid cron expression");
}
},
};
Server-Side: Controllers
// server/controllers/content-scheduler.js
module.exports = ({ strapi }) => ({
// List all schedules
async find(ctx) {
const entries = await strapi.entityService.findMany(
"plugin::content-scheduler.schedule",
{ populate: ["contentType", "entry"] }
);
return { data: entries };
},
// Create a new schedule
async create(ctx) {
const { data } = ctx.request.body;
const entry = await strapi.entityService.create(
"plugin::content-scheduler.schedule",
{ data }
);
return { data: entry };
},
// Get scheduled items for today
async today(ctx) {
const service = strapi.plugin("content-scheduler").service("scheduler");
const items = await service.getTodaySchedule();
return { data: items };
},
});
Server-Side: Services
// server/services/scheduler.js
module.exports = ({ strapi }) => ({
// Core service: publish scheduled content
async processScheduledContent() {
const now = new Date();
const schedules = await strapi.entityService.findMany(
"plugin::content-scheduler.schedule",
{
filters: {
scheduledAt: { $lte: now },
processed: false,
},
populate: ["contentType"],
}
);
const results = [];
for (const schedule of schedules) {
try {
// Publish the entry
await strapi.entityService.update(
schedule.contentType,
schedule.entryId,
{ data: { publishedAt: now } }
);
// Mark schedule as processed
await strapi.entityService.update(
"plugin::content-scheduler.schedule",
schedule.id,
{ data: { processed: true, processedAt: now } }
);
results.push({ id: schedule.id, status: "published" });
} catch (error) {
strapi.log.error(`Failed to process schedule ${schedule.id}: ${error.message}`);
results.push({ id: schedule.id, status: "failed", error: error.message });
}
}
return results;
},
async getTodaySchedule() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
return strapi.entityService.findMany(
"plugin::content-scheduler.schedule",
{
filters: {
scheduledAt: { $gte: today, $lt: tomorrow },
},
populate: ["contentType", "entry"],
}
);
},
});
Server-Side: Routes
// server/routes/content-scheduler.js
module.exports = {
type: "content-api", // or "admin"
routes: [
{
method: "GET",
path: "/schedules",
handler: "content-scheduler.find",
config: {
policies: [],
},
},
{
method: "POST",
path: "/schedules",
handler: "content-scheduler.create",
config: {
policies: [],
},
},
{
method: "GET",
path: "/schedules/today",
handler: "content-scheduler.today",
config: {
policies: [],
},
},
],
};
Server-Side: Content Types
Plugins can define their own content types:
// server/content-types/schedule/schema.json
{
"kind": "collectionType",
"collectionName": "plugin_schedules",
"info": {
"singularName": "schedule",
"pluralName": "schedules",
"displayName": "Schedule",
"description": "Content publishing schedule"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"scheduledAt": {
"type": "datetime",
"required": true
},
"processed": {
"type": "boolean",
"default": false
},
"processedAt": {
"type": "datetime"
},
"contentType": {
"type": "string",
"required": true
},
"entryId": {
"type": "integer",
"required": true
}
}
}
Admin-Side: React Components
The admin side extends Strapi's React admin panel:
// admin/src/index.js
import { prefixPluginTranslations } from "@strapi/helper-plugin";
import pluginPkg from "../../package.json";
import pluginId from "./pluginId";
export default {
register(app) {
// Register the plugin in the admin panel
app.registerPlugin({
id: pluginId,
name: "Content Scheduler",
});
},
bootstrap(app) {
// Add custom pages or components
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon: "Calendar",
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: "Scheduler",
},
Component: async () => {
const component = await import("./pages/App");
return component;
},
permissions: [],
});
app.addSettingsLink({
id: "scheduler-settings",
to: `/settings/${pluginId}`,
intlLabel: {
id: `${pluginId}.settings`,
defaultMessage: "Content Scheduler",
},
Component: async () => {
const component = await import("./pages/Settings");
return component;
},
});
},
async registerTrads({ locales }) {
// Load translations
const importedTranslations = await Promise.all(
locales.map((locale) => {
return import(`./translations/${locale}.json`)
.then(({ default: data }) => ({ data, locale }))
.catch(() => ({ data: {}, locale }));
})
);
return importedTranslations;
},
};
// admin/src/pages/App.jsx
import React, { useState, useEffect } from "react";
import { useFetchClient } from "@strapi/helper-plugin";
import { Box, Typography, Table, Thead, Tbody, Tr, Td, Th } from "@strapi/design-system";
const App = () => {
const [schedules, setSchedules] = useState([]);
const [loading, setLoading] = useState(true);
const { get } = useFetchClient();
useEffect(() => {
const fetchSchedules = async () => {
try {
const { data } = await get("/content-scheduler/schedules/today");
setSchedules(data.data);
} catch (error) {
console.error("Failed to fetch schedules", error);
} finally {
setLoading(false);
}
};
fetchSchedules();
}, []);
if (loading) return <Typography>Loading...</Typography>;
return (
<Box padding={8}>
<Typography variant="alpha">Today's Schedule</Typography>
<Table>
<Thead>
<Tr>
<Th>Content Type</Th>
<Th>Entry ID</Th>
<Th>Scheduled At</Th>
<Th>Status</Th>
</Tr>
</Thead>
<Tbody>
{schedules.map((schedule) => (
<Tr key={schedule.id}>
<Td>{schedule.contentType}</Td>
<Td>{schedule.entryId}</Td>
<Td>{new Date(schedule.scheduledAt).toLocaleString()}</Td>
<Td>{schedule.processed ? "Published" : "Pending"}</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
);
};
export default App;
Registering the Plugin
The strapi-server.js and strapi-admin.js files register the plugin with Strapi:
// strapi-server.js
module.exports = require("./server");
// strapi-admin.js
module.exports = require("./admin/src").default;
These files are the entry points that Strapi uses to load your plugin.
Common Mistakes
Not rebuilding the admin panel after admin code changes. Admin-side changes require
npm run buildto compile the React code. Server-side changes only need a server restart.Forgetting to register routes and controllers. Creating controller files without route definitions means the endpoints are unreachable. Always create corresponding routes.
Using the wrong route type.
content-apiroutes are for public API access with role permissions.adminroutes are for admin panel API access. Use the correct type for your use case.Not handling plugin configuration defaults. Plugins should work with minimal configuration. Use sensible defaults in the config schema and validate provided values.
Hardcoding paths and IDs. Plugin code should be portable. Use relative paths and avoid hardcoded content type IDs. Reference content types by their UID.
Practice Questions
What is the difference between a plugin's server and admin code? Answer: Server code runs on the Node.js backend (controllers, services, routes, content types). Admin code runs in the browser (React components, pages, translations).
How do you generate a new plugin scaffold? Answer: Run
npm run strapi generate:plugin <plugin-name>. This creates the complete plugin structure with server and admin directories.What is the purpose of the Bootstrap.js file in a plugin? Answer: It runs when Strapi starts. Use it to initialize services, set up cron jobs, or create default data. It is optional but useful for plugin initialization.
Challenge: Build a complete custom plugin from scratch: (1) Generate a plugin called "analytics-dashboard", (2) Create a server-side service that counts total entries per content type, (3) Create a custom API endpoint that returns the analytics data, (4) Build an admin page that displays the data in a table and chart, (5) Add plugin configuration for the refresh interval, (6) Register the plugin and verify it works end-to-end.
FAQ
Mini Project
Your task: Build a custom "Content Audit" plugin.
- Generate a plugin called "content-audit".
- Create a server-side service that:
- Counts entries per content type
- Calculates average entry age (time since publication)
- Identifies entries with missing SEO metadata
- Finds orphaned files (not attached to any entry)
- Create API endpoints for the audit data.
- Build an admin dashboard page showing:
- Content type summary table (type, count, avg age)
- Alerts section showing issues (missing SEO, old content, orphans)
- Refresh button to recalculate
- Add a cron job in bootstrap.js that runs a weekly audit.
- Test the plugin and verify the dashboard displays correct data.
What's Next
Now that you can build custom plugins, proceed to Admin Panel Customization to learn about branding, custom colors, and custom fields. After that, explore Email & Notifications.
Related lessons:
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro