mcpField
S
strapi-expert

strapi-expert

github.com/ayhid開発91 回インストール
リポジトリ

strapi-expert について

Strapi v5 のプラグイン、API、CMS 機能を開発するためのスキルです。Document Service API を優先し、サーバー側と管理画面の実装パターンを紹介します。

主な機能

  1. 01Document Service API によるデータ操作
  2. 02プラグインの構成と登録
  3. 03コンテンツ・管理 API ルート
  4. 04コントローラー、サービス、ポリシー
  5. 05管理画面とコンテンツマネージャーのパターン

活用シーン

  1. 01Strapi v5 プラグインの開発
  2. 02独自のコンテンツ・管理ルートの追加
  3. 03プラグインの CRUD 実装
  4. 04コンテンツマネージャーへの UI 統合

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ayhid/claude-skill-strapi-expert --skill strapi-expert

インストール後、Claude Code で/strapi-expertを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

.gitignore
CONTRIBUTING.md
README.md
examples.md
patterns.md

詳細

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.singularapi::article.article
プラグインのコンテンツタイプplugin::plugin-name.typeplugin::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 のリファレンス実装に基づいています。

設計原則
  1. ファクトリーパターン:標準的な CRUD 操作には、Strapi の factories.createCoreService()、factories.createCoreController()、factories.createCoreRouter() を使用します。
  2. サービスレイヤーパターン:ビジネスロジックはサービスに実装し、コントローラーからサービスに処理を委譲します。
  3. Admin と Content API の分離:ルートを管理パネルと公開 API に分けます。
  4. Content Manager との統合:インジェクションゾーンを使用して、既存のコンテンツマネージャービューに UI を追加します。
  5. データ取得には 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 をご覧ください。

コメント