strapi-expert について
Strapi v5のプラグイン、API、CMS機能を開発する人向けのリファレンススキルです。Document Service APIの利用を重視し、サーバーと管理画面の構成、ルート、コントローラー、サービスの実装パターンを示します。
主な機能
- 01Strapi v5 Document Serviceの解説
- 02プラグイン構成と登録パターン
- 03管理画面とContent APIのルート例
- 04コントローラーとサービスの例
- 05よくある問題とトラブルシューティング
活用シーン
- 01Strapi v5プラグインの開発
- 02カスタムAPIルートとコントローラーの追加
- 03プラグイン管理画面コンポーネントの設計
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ mkshahzad77/ claude-skill-strapi-expert --skill strapi-expertインストール後、Claude Code で/strapi-expertを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
CONTRIBUTING.md.gitignoreREADME.mdexamples.mdpatterns.mdLICENSE詳細
Strapi v5 エキスパート
プラグイン開発、カスタム API、CMS アーキテクチャを専門とする、Strapi v5 のエキスパート開発者です。公式の規約とベストプラクティスに従って、本番環境に対応した Strapi v5 コードを作成することが使命です。
基本方針:まず Document Service API を使用する
Strapi v5 では、すべてのデータ操作に必ず Document Service API(strapi.documents)を使用してください。v4 の Entity Service API は非推奨です。
Document Service と Entity Service の比較
| 操作 | Document Service(v5) | Entity Service(非推奨) |
|---|---|---|
| 複数件を検索 | strapi.documents('api::article.article').findMany() | strapi.entityService.findMany() |
| 1 件を検索 | strapi.documents(uid).findOne({ documentId }) | strapi.entityService.findOne() |
| 作成 | strapi.documents(uid).create({ data }) | strapi.entityService.create() |
| 更新 | strapi.documents(uid).update({ documentId, data }) | strapi.entityService.update() |
| 削除 | strapi.documents(uid).delete({ documentId }) | strapi.entityService.delete() |
| 公開 | strapi.documents(uid).publish({ documentId }) | 該当なし |
| 公開解除 | strapi.documents(uid).unpublish({ documentId }) | 該当なし |
Document Service の基本的な使い方
// 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,
});
プラグインの構成
Strapi v5 のプラグインは、次の構成に従います。
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 の要件
{
"name": "my-plugin",
"version": "1.0.0",
"strapi": {
"kind": "plugin",
"name": "my-plugin",
"displayName": "My Plugin"
}
}
ルート定義
Content API ルート(公開/認証済み)
// 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 ルート(管理パネル専用)
export default {
admin: {
type: 'admin',
routes: [
{
method: 'GET',
path: '/settings',
handler: 'settings.getSettings',
config: {
policies: ['admin::isAuthenticatedAdmin'],
},
},
],
},
};
コントローラー
// 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;
サービス
// 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;
コンテンツタイプのスキーマ
{
"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"
}
}
}
コンテンツタイプ UID の形式
常に正しい UID 形式を使用してください。
| 種類 | 形式 | 例 |
|---|---|---|
| API コンテンツタイプ | api::singular.singular | api::article.article |
| プラグインのコンテンツタイプ | plugin::plugin-name.type | plugin::my-plugin.item |
| ユーザー | plugin::users-permissions.user | - |
Admin Panel のコンポーネント
基本的な管理ページ
// 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;
プラグインの登録
// 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 };
}
})
);
},
};
ポリシー
// server/src/policies/is-owner.ts
export default (policyContext, config, { strapi }) => {
const { user } = policyContext.state;
if (!user) {
return false;
}
// Custom ownership logic
return true;
};
避けるべき一般的なアンチパターン
| アンチパターン | 正しい方法 |
|---|---|
| Entity Service の使用 | Document Service API を使用する |
CRUD に strapi.query() を使用する | strapi.documents() を使用する |
| UID のハードコード | 定数または設定を使用する |
| コントローラーにエラーハンドリングがない | try-catch で囲み、ctx.throw を使用する |
| データベースへの直接クエリ | フィルターを指定して Document Service を使用する |
| ポリシーを省略する | 必ず認可を実装する |
トラブルシューティングガイド
| 問題 | 解決方法 |
|---|---|
| プラグインが読み込まれない | package.json に strapi.kind: "plugin" があることを確認する |
| ルートが 404 を返す | ルートの種類(content-api と admin)とハンドラーパスを確認する |
| アクセスが拒否される | Settings > Roles で権限を設定する |
| Admin Panel が空白になる | admin/src/index.tsx のエクスポートと React のエラーを確認する |
| TypeScript エラー | strapi ts:generate-types を実行する |
| ビルドに失敗する | プラグイン内で npm run build を実行し、インポートエラーがないか確認する |
開発コマンド
# 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
プラグインアーキテクチャのベストプラクティス
Strapi Community の plugin-todo のリファレンス実装に基づいています。
設計原則
- ファクトリーパターン:標準的な CRUD 操作には、Strapi の
factories.createCoreService()、factories.createCoreController()、factories.createCoreRouter()を使用します。 - サービスレイヤーパターン:ビジネスロジックはサービスに実装し、コントローラーからサービスに処理を委譲します。
- Admin と Content API の分離:ルートを管理パネルと公開 API に分けます。
- Content Manager との統合:インジェクションゾーンを使用して、既存のコンテンツマネージャービューに UI を追加します。
- データ取得には React Query:管理パネルでのデータ取得と変更には
@tanstack/react-queryを使用します。
推奨されるプラグイン構成(plugin-todo パターン)
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 routes
モダンな Exports を備えた Package.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.0.0"
},
"peerDependencies": {
"@strapi/strapi": "^5.0.0",
"@strapi/design-system": "^2.0.0",
"react": "^17.0.0 || ^18.0.0"
}
}
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,
};
ファクトリーベースのサービス
// 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 } },
});
},
}));
ファクトリーベースのコントローラー
// 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;
},
}));
コアルーターを使用したルートの構成
// 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',
},
],
});
非表示のプラグインコンテンツタイプ(内部使用)
{
"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/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 パターン
// 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>
);
};
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>
);
};
ベストプラクティスのチェックリスト
サーバー:
- 標準的な CRUD には
factories.createCoreService()を使用します - カスタムメソッドには
factories.createCoreController()を使用します - CRUD ルートの自動生成には
factories.createCoreRouter()を使用します - ルートを
admin/ディレクトリとcontent-api/ディレクトリに分けます - 内部コンテンツタイプをコンテンツマネージャー UI から非表示にします
管理パネル:
- React Query のコンテキストには
QueryClientProviderを使用します - API 呼び出しには
useFetchClient()を使用します - 現在のエンティティ情報には
unstable_useContentManagerContext()を使用します - CM との連携には
app.getPlugin('content-manager').injectComponent()を使用します -
registerTrads()で翻訳に対応します
コンテンツタイプ:
- ポリモーフィックなリレーションには
morphToManyを使用します - 内部タイプには
pluginOptions.content-manager.visible: falseを設定します - 単数形の名前を使用します(
tasksではなくtask)
詳細なパターンについては、patterns.md をご覧ください。 実際の例については、examples.md をご覧ください。
コメント
Markdown 対応