fullstack-workflow について
GET ルートから UI フォームまで、一貫した手順でフルスタック機能を実装するための開発スキルです。Next.js、SWR、Server Action、React Hook Form、Zod を使う開発者向けです。
主な機能
- 01認証ミドルウェアを使った GET ルート
- 02データ変更用 Server Action
- 03SWR によるデータ取得とキャッシュ
- 04React Hook Form と Zod によるフォーム検証
- 05型付きレスポンスと読み込み状態の処理
活用シーン
- 01データの読み取りと更新を行う機能の開発
- 02認証が必要な API ルートの追加
- 03検証付きデータ変更フォームの作成
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ elie222/ inbox-zero --skill fullstack-workflowインストール後、Claude Code で/fullstack-workflowを入力して読み込まれたか確認します。
詳細
フルスタックのワークフロー
API から UI まで機能を構築するための完全ガイドです。GET API ルート、データ取得、フォーム処理、サーバーアクションを組み合わせます。
概要
新しい機能を構築するときは、次のパターンに従います。
- GET API ルート - データ取得用
- サーバーアクション - データの変更(作成/更新/削除)用
- データ取得 - クライアントで SWR を使用
- フォーム処理 - Zod によるバリデーションを使用した React Hook Form
1. GET API ルート
データを取得します。必ず withAuth または withEmailAccount でラップします。
// apps/web/app/api/user/example/route.ts
import { NextResponse } from "next/server";
import prisma from "@/utils/prisma";
import { withEmailAccount } from "@/utils/middleware";
// Auto-generate response type for client use
export type GetExampleResponse = Awaited<ReturnType<typeof getData>>;
export const GET = withEmailAccount(async (request) => {
const { emailAccountId } = request.auth;
const result = await getData({ emailAccountId });
return NextResponse.json(result);
});
// We make this its own function so we can infer the return type for a type-safe response on the client
async function getData({ emailAccountId }: { emailAccountId: string }) {
const items = await prisma.example.findMany({
where: { emailAccountId },
});
return { items };
}
2. サーバーアクション
データを変更します。適切なバリデーションとともに next-safe-action を使用します。
アクションクライアント(apps/web/utils/actions/safe-action.ts で定義):
| クライアント | コンテキスト | 使用する場面 |
|---|---|---|
actionClientUser | ctx.userId | 認証済みユーザーのみが必要な場合 |
actionClient | ctx.emailAccountId、ctx.userId | ユーザーとメールアカウントが必要な場合(ほとんどのデータ変更) |
adminActionClient | ctx.logger | 管理者専用アクションの場合(ctx に userId はありません) |
Sentry による計測には、必ず .metadata({ name: "actionName" }) を使用します。想定内のエラーには SafeError を使用します。
バリデーションスキーマ(apps/web/utils/actions/example.validation.ts):
import { z } from "zod";
export const createExampleBody = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
description: z.string().optional(),
});
export type CreateExampleBody = z.infer<typeof createExampleBody>;
export const updateExampleBody = z.object({
id: z.string(),
name: z.string().optional(),
email: z.string().email().optional(),
description: z.string().optional(),
});
export type UpdateExampleBody = z.infer<typeof updateExampleBody>;
サーバーアクション(apps/web/utils/actions/example.ts):
"use server";
import { actionClient } from "@/utils/actions/safe-action";
import { createExampleBody, updateExampleBody } from "@/utils/actions/example.validation";
import prisma from "@/utils/prisma";
export const createExampleAction = actionClient
.metadata({ name: "createExample" })
.inputSchema(createExampleBody)
.action(async ({
ctx: { emailAccountId },
parsedInput: { name, email, description }
}) => {
const example = await prisma.example.create({
data: {
name,
email,
description,
emailAccountId,
},
});
return example;
});
export const updateExampleAction = actionClient
.metadata({ name: "updateExample" })
.inputSchema(updateExampleBody)
.action(async ({
ctx: { emailAccountId },
parsedInput: { id, name, email, description }
}) => {
const example = await prisma.example.update({
where: { id, emailAccountId },
data: { name, email, description },
});
return example;
});
3. データ取得
クライアント側のデータ取得には SWR を使用します。
import useSWR from "swr";
import { GetExampleResponse } from "@/app/api/user/example/route";
export function useExamples() {
return useSWR<GetExampleResponse>("/api/user/example");
}
4. フォーム処理
next-safe-action/hooks の useAction と React Hook Form を使用します。
import { useCallback } from "react";
import { useForm, type SubmitHandler } from "react-hook-form";
import { useAction } from "next-safe-action/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/Input";
import { Button } from "@/components/ui/button";
import { toastSuccess, toastError } from "@/components/Toast";
import { getActionErrorMessage } from "@/utils/error";
import { createExampleAction } from "@/utils/actions/example";
import { createExampleBody, type CreateExampleBody } from "@/utils/actions/example.validation";
export function ExampleForm({ onSuccess }: { onSuccess?: () => void }) {
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<CreateExampleBody>({
resolver: zodResolver(createExampleBody),
});
const { execute, isExecuting } = useAction(createExampleAction, {
onSuccess: () => {
toastSuccess({ description: "Example created!" });
reset();
onSuccess?.();
},
onError: (error) => {
toastError({
description: getActionErrorMessage(error.error),
});
},
});
return (
<form className="space-y-4" onSubmit={handleSubmit(execute)}>
<Input
type="text"
name="name"
label="Name"
registerProps={register("name")}
error={errors.name}
/>
<Input
type="email"
name="email"
label="Email"
registerProps={register("email")}
error={errors.email}
/>
<Input
type="text"
name="description"
label="Description"
registerProps={register("description")}
error={errors.description}
/>
<Button type="submit" loading={isExecuting}>
Create Example
</Button>
</form>
);
}
5. 完全なデータ取得コンポーネント
'use client';
import { useExamples } from "@/hooks/useExamples";
import { Button } from "@/components/ui/button";
import { LoadingContent } from "@/components/LoadingContent";
export function Examples() {
const { data, isLoading, error } = useExamples();
return (
<LoadingContent loading={isLoading} error={error}>
<div className="grid gap-4">
{data?.examples.map((example) => (
<div key={example.id} className="border p-4 rounded">
<h3 className="font-semibold">{example.name}</h3>
<p className="text-gray-600">{example.email}</p>
{example.description && (
<p className="text-sm text-gray-500">{example.description}</p>
)}
</div>
))}
</div>
</LoadingContent>
);
}
主なガイドライン
認証と認可
- ユーザーレベルの操作には
withAuthを使用します - メールアカウントレベルの操作には
withEmailAccountを使用します - サーバーアクションには適切なコンテキストが自動的に設定されます
データ変更
- すべてのデータ変更(作成/更新/削除)にサーバーアクションを使用します
- データ変更に POST API ルートは使用しないでください。代わりにサーバーアクションを使用します
エラー処理
onSuccessとonErrorコールバックを指定したuseActionフックを使用します@/utils/errorのgetActionErrorMessage(error.error)を使用して、ユーザーにわかりやすいメッセージを抽出します- プレフィックスとエラーを組み合わせる場合:
getActionErrorMessage(error.error, { prefix: "Failed to save" }) next-safe-actionは、フラット化されたバリデーションエラーを含む一元的なエラー処理を提供します- ミドルウェアを使用する GET ルートでは、try/catch は不要です
型安全性
- GET ルートからレスポンスの型をエクスポートします
- クライアントとサーバーの両方で、バリデーションに Zod スキーマを使用します
- TypeScript の型推論を活用して、DX を向上させます
読み込み状態とエラー状態
LoadingContentコンポーネントを使用して、読み込み状態とエラー状態を一貫して処理しますLoadingContentにloading、error、children の各プロパティを渡します- これにより、読み込みスピナーやエラーメッセージを標準化された方法で表示できます
パフォーマンス
- 効率的なデータ取得とキャッシュには SWR を使用します
- データ変更が成功した後に
mutate()を呼び出して、データを更新します
ファイル構成
apps/web/
├── app/api/user/example/route.ts # GET API route
├── utils/actions/example.validation.ts # Zod schemas
├── utils/actions/example.ts # Server actions
├── hooks/useExamples.ts # SWR hook
└── components/ExampleForm.tsx # Form component
関連ルール
コメント
Markdown 対応