✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem18 min read

How to Build a Strapi Plugin with GitHub Copilot: From Idea to Marketplace

April 23, 2026Updated on June 14, 2026

Building a custom Strapi plugin usually means juggling boilerplate code, Strapi-specific conventions, and a fair amount of time spent reading documentation. GitHub Copilot can compress that timeline significantly, if you know how to prompt it with the right context.

This guide walks you through building a Todo List plugin for Strapi 5, from initial scaffolding to Marketplace submission, using GitHub Copilot at every step. The Todo plugin integrates directly into the Admin Panel, giving content editors a task management tool attached to their workflow. It's small enough to complete in one sitting but covers every major plugin concept: server routes, controllers, services, Content-Type schemas, and React admin components.

In brief:

  • Scaffold a Strapi 5 plugin using the Plugin SDK and configure your workspace so Copilot generates framework-aligned code.
  • Build complete Create, Read, Update, Delete (CRUD) backend logic, including routes, controllers, and services, with Copilot Chat prompts that reference the Document Service API.
  • Create an admin panel interface using the Design System components, generated through context-rich Copilot prompts.
  • Test, publish to npm, and submit to the Strapi Marketplace with a field-by-field checklist.

Prerequisites

Before starting, make sure you have:

  • Node.js 18+ and npm or Yarn installed
  • A Strapi 5 plugin project scaffolded locally (npx @strapi/sdk-plugin init my-strapi-plugin)
  • VS Code with the GitHub Copilot extension installed and an active Copilot subscription
  • Working knowledge of TypeScript, React, and Node.js

Scaffold the Plugin and Configure Your Workspace

Strapi 5 introduced the Plugin SDK, which creates plugins as standalone npm packages, a major shift from v4's in-project generator. This means your plugin lives outside your Strapi application, gets its own versioning, and can be distributed independently.

Start by initializing the plugin:

npx @strapi/sdk-plugin init strapi-plugin-todo

This command generates the plugin structure:

strapi-plugin-todo/
├── package.json
├── src/
│   ├── admin/
│   │   ├── src/
│   │   │   ├── components/
│   │   │   ├── containers/
│   │   │   ├── pages/
│   │   │   └── index.ts        ← Admin entry point
│   │   └── translations/
│   ├── server/
│   │   ├── controllers/
│   │   ├── services/
│   │   ├── routes/
│   │   └── bootstrap.ts
│   └── index.ts                ← Root entry point
└── README.md

The src/admin/ directory holds all React code for the Admin Panel. The src/server/ directory contains your backend logic: controllers, services, and routes. Both get bundled through the consolidated src/index.ts entry point.

Set Up Local Linking

To test the plugin inside your Strapi project, use yalc (not npm link, which causes dependency duplication issues, as documented in issue #22985):

# In the plugin directory: start watch mode
strapi-plugin watch:link

# In your Strapi project directory: link the plugin
npx yalc add --link strapi-plugin-todo && npm install

Then start Strapi with hot-reload:

npm run develop

Optimize VS Code for Copilot Context

This step matters more than most people realize. Per the Copilot practices, Copilot generates better code when it can see relevant files in your workspace.

Open a single VS Code workspace that includes both the plugin directory and your test Strapi project. Keep files open strategically based on what you're generating:

TaskKeep These Files Open
Generating server configsrc/index.ts, existing routes file
Generating controllersRoute definitions file
Generating admin componentsExisting plugin components, Design System import examples
Generating servicesSchema/model files

Close unrelated files. Copilot draws context from open tabs, so a cluttered workspace means noisier suggestions.

Build the Server-Side with Copilot

The backend follows a predictable pattern: define routes, then controllers that handle requests, then services that contain business logic. This predictability is exactly what makes comment-driven dev effective for Strapi plugins.

Generate Route Definitions

Create src/server/routes/index.ts and add descriptive comments that act as prompts:

// Define RESTful routes for todo resource
// GET /todos - list all todos with pagination
// GET /todos/:id - get single todo by ID
// POST /todos - create new todo
// PUT /todos/:id - update existing todo
// DELETE /todos/:id - delete todo
// Use Strapi's plugin route structure

Copilot's inline suggestions should generate route objects matching Strapi's expected format. Accept the suggestions with Tab, then review. The generated output should look something like this:

export default [
  {
    method: 'GET',
    path: '/todos',
    handler: 'todo.find',
    config: { policies: [], auth: false },
  },
  {
    method: 'GET',
    path: '/todos/:id',
    handler: 'todo.findOne',
    config: { policies: [], auth: false },
  },
  {
    method: 'POST',
    path: '/todos',
    handler: 'todo.create',
    config: { policies: [] },
  },
  {
    method: 'PUT',
    path: '/todos/:id',
    handler: 'todo.update',
    config: { policies: [] },
  },
  {
    method: 'DELETE',
    path: '/todos/:id',
    handler: 'todo.delete',
    config: { policies: [] },
  },
];

Verify that each handler string follows the controllerName.methodName convention and that auth settings match your access requirements. The specificity of your comments directly affects output quality.

Generate the Controller

This is where Copilot Chat shines over inline suggestions. Open the chat panel and use a context-rich prompt, the kind the prompt guide recommends:

Generate a Strapi plugin controller for todos that:
- Uses Strapi's Document Service API (strapi.documents)
- Implements find, findOne, create, update, delete methods
- Includes input validation using @strapi/utils
- Returns standardized JSON responses { data: [], meta: {} }
- Handles errors with proper HTTP status codes
- Follows async/await patterns

Notice the explicit mention of strapi.documents(). This is the Document Service API that replaced the deprecated Entity Service API from v4. Without this specificity, Copilot may generate v4-style patterns using strapi.entityService, which is deprecated in Strapi 5 and should generally be replaced with strapi.documents().

The generated controller's core methods should use patterns like this:

async find(ctx) {
  const todos = await strapi.documents('plugin::todo.todo').findMany({
    ...ctx.query,
  });
  return { data: todos, meta: { pagination: { total: todos.length } } };
},

async create(ctx) {
  const { body } = ctx.request;
  if (!body.title) {
    return ctx.badRequest('Title is required');
  }
  const todo = await strapi.documents('plugin::todo.todo').create({
    data: body,
  });
  return { data: todo };
},

The key detail is the 'plugin::todo.todo' UID string, which references the plugin-scoped Content-Type. Place the generated code in src/server/controllers/todo.ts.

Generate the Service Layer

For the server API, keep the controller file open so Copilot understands the relationship. In src/server/services/todo.ts, add:

// Create a service that encapsulates todo business logic
// Should call strapi.documents() methods
// Include methods for: getAll, getById, create, update, remove
// Add custom method to calculate completion statistics

Copilot will generate methods that align with the controller's expectations. The @workspace context variable is useful here. Try:

@workspace Generate a service layer for the todo plugin that matches 
the methods called in #file:src/server/controllers/todo.ts

This helps the service interface align more closely with what the controller expects. The generated service should look something like this:

const todoService = ({ strapi }) => ({
  async getAll(params = {}) {
    return await strapi.documents('plugin::todo.todo').findMany(params);
  },

  async getById(id: string) {
    return await strapi.documents('plugin::todo.todo').findOne({ documentId: id });
  },

  async create(data: Record<string, unknown>) {
    return await strapi.documents('plugin::todo.todo').create({ data });
  },

  async update(id: string, data: Record<string, unknown>) {
    return await strapi.documents('plugin::todo.todo').update({
      documentId: id,
      data,
    });
  },

  async remove(id: string) {
    return await strapi.documents('plugin::todo.todo').delete({ documentId: id });
  },

  async getCompletionStats() {
    const todos = await strapi.documents('plugin::todo.todo').findMany({});
    const completed = todos.filter((t) => t.isComplete).length;
    return { total: todos.length, completed, pending: todos.length - completed };
  },
});

export default todoService;

The service layer abstracts all database operations away from the controller, which makes both layers independently testable and reusable. Your controller methods become thin wrappers that validate input, call the appropriate service method, and format the response.

If Copilot generates service methods that don't align with what the controller calls, use the #file reference in Copilot Chat to re-anchor the generation against your controller's actual method invocations.

Define the Todo Content-Type Schema

Every plugin needs a Content-Type schema to define its data model. This is what tells Strapi how to create and manage your plugin's database tables. Use Copilot Chat to generate the schema:

Generate a Strapi plugin content-type schema for a todo item with:
- title: string, required, max 255 characters
- description: text, optional
- status: enumeration with values pending, in-progress, done, default pending
- dueDate: date, optional
- isComplete: boolean, default false
- Use plugin::todo.todo as the UID
- Set kind to collectionType

Place the generated schema in src/server/content-types/todo/schema.json:

{
  "kind": "collectionType",
  "collectionName": "todos",
  "info": {
    "singularName": "todo",
    "pluralName": "todos",
    "displayName": "Todo"
  },
  "options": {
    "draftAndPublish": false
  },
  "attributes": {
    "title": {
      "type": "string",
      "required": true,
      "maxLength": 255
    },
    "description": {
      "type": "text"
    },
    "status": {
      "type": "enumeration",
      "enum": ["pending", "in-progress", "done"],
      "default": "pending"
    },
    "dueDate": {
      "type": "date"
    },
    "isComplete": {
      "type": "boolean",
      "default": false
    }
  }
}

This Collection Type schema is registered at startup when it is included in the contentTypes object exported from the server entry point. Per the Strapi docs, Strapi registers these content-types at startup, and their schemas define the corresponding database table names. Export the todo schema as part of the contentTypes object from your plugin's main server entry file, optionally importing it from a separate file for organization.

Wire Up the Server Entry Point

The server entry exports the plugin's server API components and lifecycle hooks, such as routes, controllers, services, contentTypes, and the register, bootstrap, and destroy hooks:

import routes from './routes';
import controllers from './controllers';
import services from './services';
import contentTypes from './content-types';

export default {
  register({ strapi }) {
    // Executes first: static config, extension registration
  },
  bootstrap({ strapi }) {
    // Executes after all plugins register: async init
  },
  destroy({ strapi }) {
    // Cleanup on shutdown
  },
  routes,
  controllers,
  services,
  contentTypes,
};

The registerbootstrapdestroy lifecycle order is important. Across plugins, register runs during an earlier startup phase, and bootstrap runs later once the application is fully initialized. That's where you'd subscribe to lifecycle events or set up inter-plugin dependencies.

Build the Admin Panel with Copilot

Here's a critical architectural concept: Strapi's admin panel is separate from the server. Data flows from your server-side plugin code to your admin components through HTTP requests, often using the useFetchClient hook.

Generate the Todo List Component

Open Copilot Chat and provide a prompt that references the Design System. Strapi v5 plugins use the updated Strapi Design System v2 for the admin UI:

Create a React admin component for managing todos using Strapi Design System components:
- Import components from @strapi/design-system
- Use Table component for list view with columns: title, status, dueDate
- Include Button components for create/edit/delete actions
- Implement Form with TextInput and Select for status
- Add validation using formik or react-hook-form
- Style with Strapi's Box, Flex, and Typography components
- Handle API calls using useFetchClient hook
- Show loading state with Loader component
- Handle empty state with EmptyStateLayout

From a context-rich prompt like this, Copilot typically generates the core structure of the component. The key parts should look something like this:

import { useEffect, useState } from 'react';
import {
  Table, Thead, Tbody, Tr, Td, Th,
  Typography, Button, Box, Flex,
  Loader, EmptyStateLayout,
} from '@strapi/design-system';
import { useFetchClient } from '@strapi/strapi/admin';

const TodoPage = () => {
  const { get, post, del } = useFetchClient();
  const [todos, setTodos] = useState([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const fetchTodos = async () => {
      const { data } = await get('/todo/todos');
      setTodos(data.data);
      setIsLoading(false);
    };
    fetchTodos();
  }, []);

  if (isLoading) return <Loader>Loading todos...</Loader>;
  if (todos.length === 0) return <EmptyStateLayout content="No todos yet" />;

  return (
    <Box padding={8}>
      <Flex justifyContent="space-between" paddingBottom={4}>
        <Typography variant="alpha">Todo Manager</Typography>
        <Button onClick={handleCreate}>Add Todo</Button>
      </Flex>
      <Table colCount={3} rowCount={todos.length}>
        <Thead>
          <Tr><Th>Title</Th><Th>Status</Th><Th>Due Date</Th></Tr>
        </Thead>
        <Tbody>
          {todos.map((todo) => (
            <Tr key={todo.id}>
              <Td><Typography>{todo.title}</Typography></Td>
              <Td><Typography>{todo.status}</Typography></Td>
              <Td><Typography>{todo.dueDate}</Typography></Td>
            </Tr>
          ))}
        </Tbody>
      </Table>
    </Box>
  );
};

Expect Copilot to generate a substantial portion of what you need from a well-crafted prompt. You'll still need to adjust column names to match your exact schema, wire up button handlers for create and delete actions, and refine error states to use Strapi's Notification API. Treat the generated output as a strong starting point that you iterate on, not a finished component.

Place the generated component in your plugin's admin source, following the Strapi v5 plugin structure under src/plugins/<plugin-name>/admin/src/.

Connect the Admin to Your API

The useFetchClient hook is commonly used in Strapi admin components to make HTTP requests to plugin or admin-side endpoints. It appears to wrap an Axios-based client, but official documentation does not clearly confirm its authentication-token handling or base URL configuration details:

import { useFetchClient } from '@strapi/strapi/admin';

const TodoPage = () => {
  const { get, post, del } = useFetchClient();
  const [todos, setTodos] = useState([]);

  const fetchTodos = async () => {
    const { data } = await get('/todo/todos');
    setTodos(data.data);
  };

  const createTodo = async (newTodo) => {
    await post('/todo/todos', newTodo);
    await fetchTodos();
  };

  const deleteTodo = async (id) => {
    await del(`/todo/todos/${id}`);
    await fetchTodos();
  };
};

All API calls from the admin panel go through routes prefixed by your plugin name by default, such as /todo/ for a plugin named todo. The fetch client handles requests for you, but consult the current Strapi documentation to confirm the correct path format for your calls.

This separation between admin and server is fundamental to Strapi's architecture: the admin panel is separate from the server/database layer and communicates with the backend through HTTP/API endpoints rather than accessing the database directly.

Register the Plugin in the Admin Panel

The admin entry point in admin/src/index.ts uses the Admin Panel API to register your plugin and add navigation:

app.registerPlugin({
  id: 'todo',
  name: 'Todo'
});

To make your plugin accessible from the Strapi sidebar, add a menu link in the register function:

app.addMenuLink({
  to: '/plugins/todo',
  icon: CheckCircle,
  intlLabel: {
    id: 'todo.plugin.name',
    defaultMessage: 'Todo Manager',
  },
  Component: React.lazy(() => import('./pages/HomePage')),
});

The to path determines the URL where your plugin's admin page renders. When a user clicks the sidebar link, Strapi loads the component at that route. Using React.lazy() for the Component property ensures your plugin's page is code-split from the main admin bundle, which keeps the admin panel's initial load time fast even as you add more plugins.

The register(app) function is where you add menu links and register custom fields, while the bootstrap(app) function is for initialization that runs after plugins are registered and after Strapi has been set up.

Copilot Prompt Quality Matters

The difference between a generic prompt and a context-rich one is significant. The prompt guide emphasizes starting general and then getting specific, along with adding examples, clarifying ambiguity, and providing relevant context:

Generic (produces unusable code):

// Create a component for todos

Framework-specific (produces workable but incomplete code):

// Create a Strapi v5 admin component for todos using Design System

Context-rich (can produce higher-quality output):

// Create a Strapi v5 admin component for managing todos
// Import Table, Thead, Tbody, Tr, Td from @strapi/design-system
// Use useFetchClient from @strapi/strapi/admin for API calls
// Call GET /todo/todos for list, POST /todo/todos for create
// Include pagination controls and loading states
// Handle errors with Strapi's Notification API

That third version gives Copilot more Strapi-specific context, which can improve the chances of generating code you can use with fewer edits.

Test the Plugin and Avoid Common Pitfalls

Most Strapi plugin tutorials stop at manual verification. It helps to go beyond manual verification here. Anything you export should have tests, per the frontend guidelines.

Generate Unit Tests with Copilot

Highlight your controller or service function in VS Code, then use the /tests slash command in Copilot Chat. For more control, use a targeted prompt:

Generate Jest unit tests for this Strapi controller:
- Mock strapi.documents() methods
- Test successful CRUD operations
- Test error handling scenarios
- Test permission checks
- Include expect assertions for response format
- Use supertest for HTTP endpoint testing
- Mock authentication context (ctx.state.user)

A key to testable Strapi plugin code is mocking the specific Strapi APIs your code touches, such as the query layer or Document Service API. Here's how to set up the mock structure:

const mockStrapi = {
  documents: jest.fn().mockReturnValue({
    findMany: jest.fn().mockResolvedValue([{ id: 1, title: 'Test Todo', status: 'pending' }]),
    findOne: jest.fn().mockResolvedValue({ id: 1, title: 'Test Todo' }),
    create: jest.fn().mockResolvedValue({ id: 2, title: 'New Todo', status: 'pending' }),
    update: jest.fn().mockResolvedValue({ id: 1, title: 'Updated Todo' }),
    delete: jest.fn().mockResolvedValue({ id: 1 }),
  }),
};

This mock replaces the real database layer, letting you verify that your controller calls the right methods with the right arguments without a running Strapi instance. Here's how a complete test case uses this mock:

describe('Todo Controller', () => {
  it('should return all todos with correct response shape', async () => {
    global.strapi = mockStrapi;
    const ctx = { query: {} };

    const result = await todoController.find(ctx);

    expect(mockStrapi.documents).toHaveBeenCalledWith('plugin::todo.todo');
    expect(result).toHaveProperty('data');
    expect(result).toHaveProperty('meta.pagination.total');
    expect(result.data).toHaveLength(1);
    expect(result.data[0].title).toBe('Test Todo');
  });
});

This pattern, mock setup, method invocation, and assertion, scales cleanly to all your CRUD operations and error scenarios. Copy the structure, swap out the method name and expected values, and you have comprehensive coverage.

Per the testing docs, pure unit tests are ideal for plugins because they validate logic without starting a full Strapi server. Also run strapi ts:generate-types per the TypeScript docs to generate accurate TypeScript types for your project schemas, which can improve editor autocomplete and test type safety.

Run Integration Tests

For endpoint-level testing with Strapi, the officially documented stack is Jest + Supertest:

npm install --save-dev jest supertest @types/jest ts-jest

A basic integration test verifies your routes respond correctly:

import request from 'supertest';

describe('Todo Plugin API', () => {
  it('should return todos list', async () => {
    const res = await request(strapi.server.httpServer)
      .get('/api/todo/todos');
    expect(res.statusCode).toBe(200);
    expect(res.body).toHaveProperty('data');
    expect(res.body).toHaveProperty('meta');
  });
});

Verify the Build

Before publishing, run the Plugin SDK verification:

npm run build && npm run verify

The verify command checks that the plugin output is valid and ready to be published. Run both commands in sequence before publishing.

Common Pitfalls

A few issues surface repeatedly in GitHub issues and are worth addressing proactively:

Don't add @strapi/strapi as a devDependency in your plugin. Per issue #22536, this can result in X must be used within StrapiApp errors when developing a local plugin. Shared dependencies belong in the root Strapi project.

Declare all plugin dependencies in package.json. Per issue #22547, adding dependencies to local plugins can cause build failures during the Strapi build process.

Name your config file correctly. Per issue #8418, the correct plugin configuration filename in Strapi v3.2.4 is ./config/plugins.js, and this file is recognized as intended.

Publish to npm and Submit to the Marketplace

Prepare Your package.json

The Marketplace guidelines and Plugin SDK docs require specific fields:

{
  "name": "strapi-plugin-todo",
  "version": "1.0.0",
  "description": "A task management plugin for Strapi admin panel",
  "keywords": ["strapi", "plugin", "todo", "task-management"],
  "license": "MIT",
  "engines": {
    "node": ">=18.0.0 <=20.x.x",
    "npm": ">=6.0.0"
  },
  "main": "./dist/server/index.js",
  "exports": {
    "./server": "./dist/server/index.js",
    "./admin": "./dist/admin/index.js"
  }
}

The keywords array must include "strapi" and "plugin" for npm registry categorization. The name field in package.json is the actual package name. Per the plugin docs, this isn't necessarily the folder name.

Publish to npm

npm publish --access public

Your plugin must be publicly available on npm before submitting to the Marketplace.

Submit to the Marketplace

Head to the submission form and fill in:

FieldRequirement
TypePlugin
Display NameTodo Manager (avoid hyphens in display names)
Description50–150 characters
Logo250×250 to 600×600 px, JPG or PNG
npm URLYour public npm package URL

A few non-negotiable rules from the Marketplace guidelines: your plugin must be completely free (no paid tiers), and if it tracks usage data, users must be alerted and given an opt-out. The review process takes up to seven business days.

Pre-Submission Checklist

  • Published to npm with public access
  • keywords includes "strapi" and "plugin"
  • build and verify pass cleanly
  • README covers installation, configuration, and usage
  • Design System v2 is commonly used in Strapi admin UI examples
  • Unit tests cover exported functions
  • Version compatibility documented (note: Strapi's create-a-plugin docs do not currently provide specific guidance on this)
  • Plugin availability and pricing depend on the specific plugin
  • Description is 50–150 characters
  • Logo prepared at correct dimensions

Next Steps

You now have a workflow for building Strapi plugins with AI assistance, from scaffolding through potential Marketplace submission. The Todo plugin covers the core concepts, but the same patterns apply to more complex projects like content schedulers, approval workflows, or integration plugins that connect Strapi to third-party services like Slack or GitHub.

A few directions to explore from here: add lifecycle hooks in your bootstrap function for notifications when todos are completed. Build custom fields that attach todo counts to existing Content-Types. Add role-based access control using Strapi's permissions system, so only specific admin roles can create or delete todos. Or use Copilot's /tests command to expand your test coverage before publishing updates.

The Copilot workflow described in this article improves with repetition. As you build more plugins, you develop a library of effective prompts, and Copilot learns from the patterns in your open files. Consider keeping a markdown file of the best Strapi-specific Copilot prompts in your workspace. It serves double duty as both documentation for your team and a high-quality context that Copilot can reference for future suggestions. Over time, this prompt library becomes one of your most valuable development assets.

Study existing plugins on the Strapi Marketplace to understand common architectural patterns, and don't hesitate to read the source code of official plugins like strapi-plugin-seo for real-world examples of how production plugins structure their server and admin code.

The key takeaway for working with Copilot on Strapi projects is this: specificity is everything. Reference strapi.documents() instead of generic database calls. Name Design System components explicitly. Mention the v5 API surface. The more Strapi-specific context you give Copilot, the less time you spend fixing its output. Check out integrations and explore Strapi features to see where your next plugin idea fits.

Build a Gift Registry Platform
Ecosystem·20 min read

How to Build a Gift Registry and Wishlist Platform with Next.js 16 and Strapi 5

Gift registries have a tricky requirement most apps don't: the owner should never know who claimed what. A wedding...

·June 26, 2026
Build a Mental Health Journal
Ecosystem·23 min read

How to Build a Mental Health Journal and Mood Tracker with Next.js 16 and Strapi 5

Tracking your mood over time sounds simple until you try to build it: every entry has to stay private, the trends need...

·June 26, 2026
Ecosystem·22 min read

How to Build a Webinar and Workshop Registration Platform with Next.js 16 and Strapi 5

Running webinars and workshops sounds simple until you hit the operational details: capacity limits that need...

·June 26, 2026