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-todoThis 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.mdThe 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 installThen start Strapi with hot-reload:
npm run developOptimize 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:
| Task | Keep These Files Open |
|---|---|
| Generating server config | src/index.ts, existing routes file |
| Generating controllers | Route definitions file |
| Generating admin components | Existing plugin components, Design System import examples |
| Generating services | Schema/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 structureCopilot'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 patternsNotice 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 statisticsCopilot 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.tsThis 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 collectionTypePlace 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 register → bootstrap → destroy 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 EmptyStateLayoutFrom 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 todosFramework-specific (produces workable but incomplete code):
// Create a Strapi v5 admin component for todos using Design SystemContext-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 APIThat 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-jestA 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 verifyThe 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 publicYour plugin must be publicly available on npm before submitting to the Marketplace.
Submit to the Marketplace
Head to the submission form and fill in:
| Field | Requirement |
|---|---|
| Type | Plugin |
| Display Name | Todo Manager (avoid hyphens in display names) |
| Description | 50–150 characters |
| Logo | 250×250 to 600×600 px, JPG or PNG |
| npm URL | Your 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
keywordsincludes"strapi"and"plugin"buildandverifypass 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.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.