Skip to content

Strapi Custom Plugins — Plugin Structure, Server vs Admin Code

DodaTech Updated 2026-06-28 9 min read

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

  1. Not rebuilding the admin panel after admin code changes. Admin-side changes require npm run build to compile the React code. Server-side changes only need a server restart.

  2. Forgetting to register routes and controllers. Creating controller files without route definitions means the endpoints are unreachable. Always create corresponding routes.

  3. Using the wrong route type. content-api routes are for public API access with role permissions. admin routes are for admin panel API access. Use the correct type for your use case.

  4. Not handling plugin configuration defaults. Plugins should work with minimal configuration. Use sensible defaults in the config schema and validate provided values.

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

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

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

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

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

Can I use npm packages in my custom plugin?

Yes. Plugins are npm packages. You can add dependencies to your plugin's package.json and they will be installed when the plugin is installed.

How do I access my plugin services from other parts of Strapi?

Use strapi.plugin('plugin-name').service('serviceName') to access plugin services from controllers, lifecycle hooks, or other plugins.

Can my plugin define custom fields?

Yes. Custom fields let you create new field types in the Content-Type Builder. This is an advanced plugin feature documented in Strapi's plugin development guide.

How do I distribute my plugin?

Publish it to npm with the @strapi/plugin- prefix convention. Add proper documentation and metadata. Submit it to the Strapi marketplace for wider visibility.

Can plugins be disabled per environment?

Yes. In config/plugins.js, you can conditionally enable plugins based on environment variables: enabled: process.env.ENABLE_ANALYTICS === 'true'.

Mini Project

Your task: Build a custom "Content Audit" plugin.

  1. Generate a plugin called "content-audit".
  2. 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)
  3. Create API endpoints for the audit data.
  4. 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
  5. Add a cron job in bootstrap.js that runs a weekly audit.
  6. 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