Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Complete fullstack workflow combining GET API routes, server actions, SWR data fetching, and form handling. Use when building features that need both data fetching and mutations from API to UI.
.claude/skills/elie222-fullstack-workflow/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-09 | ✗→✓ | ▲ Improved | 24% | 0% |
| case-01 | ✗→✓ | ▲ Improved | -2% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 38% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 0% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 127% | 0% |
Complete guide for building features from API to UI, combining GET API routes, data fetching, form handling, and server actions.
When building a new feature, follow this pattern:
For fetching data. Always wrap with withAuth or withEmailAccount:
typescript// 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 }; }
For mutations. Use next-safe-action with proper validation.
Action clients (defined in apps/web/utils/actions/safe-action.ts):
| Client | Context | Use when | |--------|---------|----------| | actionClientUser | ctx.userId | Only need authenticated user | | actionClient | ctx.emailAccountId, ctx.userId | Need user + email account (most mutations) | | adminActionClient | ctx.logger | Admin-only actions (no userId in ctx) |
Always use .metadata({ name: "actionName" }) for Sentry instrumentation. Use SafeError for expected errors.
Validation Schema (apps/web/utils/actions/example.validation.ts):
typescriptimport { 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>;
Server Action (apps/web/utils/actions/example.ts):
typescript"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; });
Use SWR for client-side data fetching:
typescriptimport useSWR from "swr"; import { GetExampleResponse } from "@/app/api/user/example/route"; export function useExamples() { return useSWR<GetExampleResponse>("/api/user/example"); }
Use React Hook Form with useAction from next-safe-action/hooks:
typescriptimport { 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> ); }
typescript'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 for user-level operationswithEmailAccount for email-account-level operationsuseAction hook with onSuccess and onError callbacksgetActionErrorMessage(error.error) from @/utils/error to extract user-friendly messagesgetActionErrorMessage(error.error, { prefix: "Failed to save" })next-safe-action provides centralized error handling with flattened validation errorsLoadingContent component to handle loading and error states consistentlyloading, error, and children props to LoadingContentmutate() after successful mutations to refresh dataapps/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| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-09 | fail→pass | 15,623 | 6,664 | -57% | 1 | 1 | 0% | 2,899 | 3,594 | +24% | 0 | 0 | — |
case-01 | fail→pass | 19,149 | 9,406 | -51% | 1 | 1 | 0% | 4,220 | 4,133 | -2% | 0 | 0 | — |
case-02 | fail→pass | 16,259 | 11,721 | -28% | 1 | 1 | 0% | 3,688 | 5,100 | +38% | 0 | 0 | — |
case-03 | fail→pass | 25,147 | 15,375 | -39% | 1 | 1 | 0% | 5,823 | 5,846 | +0% | 0 | 0 | — |
case-04 | pass→pass | 11,509 | 9,890 | -14% | 1 | 1 | 0% | 2,407 | 4,345 | +81% | 0 | 0 | — |
case-05 | pass→pass | 11,051 | 6,728 | -39% | 1 | 1 | 0% | 2,228 | 3,674 | +65% | 0 | 0 | — |
case-06 | pass→pass | 8,834 | 7,264 | -18% | 1 | 1 | 0% | 1,603 | 3,549 | +121% | 0 | 0 | — |
case-07 | fail→pass | 8,563 | 6,375 | -26% | 1 | 1 | 0% | 1,594 | 3,621 | +127% | 0 | 0 | — |
case-08 | fail→pass | 14,811 | 5,200 | -65% | 1 | 1 | 0% | 2,782 | 3,336 | +20% | 0 | 0 | — |
case-10 | fail→pass | 12,042 | 5,400 | -55% | 1 | 1 | 0% | 2,078 | 3,186 | +53% | 0 | 0 | — |
case-11 | pass→pass | 14,857 | 3,816 | -74% | 1 | 1 | 0% | 3,013 | 2,881 | -4% | 0 | 0 | — |
case-12 | fail→pass | 17,406 | 7,296 | -58% | 1 | 1 | 0% | 3,064 | 3,646 | +19% | 0 | 0 | — |
case-13 | fail→pass | 10,934 | 3,356 | -69% | 1 | 1 | 0% | 2,111 | 2,933 | +39% | 0 | 0 | — |
case-14 | pass→pass | 14,002 | 7,721 | -45% | 1 | 1 | 0% | 2,361 | 3,702 | +57% | 0 | 0 | — |
case-15 | fail→pass | 12,787 | 5,232 | -59% | 1 | 1 | 0% | 2,409 | 3,221 | +34% | 0 | 0 | — |
case-16 | fail→pass | 10,811 | 7,016 | -35% | 1 | 1 | 0% | 1,972 | 3,316 | +68% | 0 | 0 | — |
case-17 | fail→pass | 15,301 | 4,320 | -72% | 1 | 1 | 0% | 2,797 | 3,098 | +11% | 0 | 0 | — |
case-18 | pass→pass | 14,273 | 8,697 | -39% | 1 | 1 | 0% | 2,572 | 3,622 | +41% | 0 | 0 | — |
case-19 | fail→pass | 15,668 | 9,556 | -39% | 1 | 1 | 0% | 2,977 | 4,270 | +43% | 0 | 0 | — |
case-20 | fail→pass | 7,158 | 4,967 | -31% | 1 | 1 | 0% | 1,316 | 3,166 | +141% | 0 | 0 | — |
case-21 | pass→pass | 15,375 | 3,844 | -75% | 1 | 1 | 0% | 2,656 | 2,925 | +10% | 0 | 0 | — |
case-22 | pass→pass | 13,800 | 4,427 | -68% | 1 | 1 | 0% | 2,578 | 3,059 | +19% | 0 | 0 | — |
case-23 | fail→pass | 15,730 | 3,133 | -80% | 1 | 1 | 0% | 2,548 | 2,830 | +11% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +65 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.