Fullstack Workflow
Complete guide for building features from API to UI, combining GET API routes, data fetching, form handling, and server actions.
Overview
When building a new feature, follow this pattern:
- GET API Route - For fetching data
- Server Action - For mutations (create/update/delete)
- Data Fetching - Using SWR on the client
- Form Handling - Using React Hook Form with Zod validation
1. GET API Route
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 }; }
2. Server Action
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; });
3. Data Fetching
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"); }
4. Form Handling
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> ); }
5. Complete Data Fetching Component
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> ); }
Key Guidelines
Authentication & Authorization
- Use
withAuthfor user-level operations - Use
withEmailAccountfor email-account-level operations - Server actions automatically get the right context
Mutations
- Use server actions for all mutations (create/update/delete operations)
- Do NOT use POST API routes for mutations - use server actions instead
Error Handling
- Use
useActionhook withonSuccessandonErrorcallbacks - Use
getActionErrorMessage(error.error)from@/utils/errorto extract user-friendly messages - For prefix + error pattern:
getActionErrorMessage(error.error, { prefix: "Failed to save" }) next-safe-actionprovides centralized error handling with flattened validation errors- No need for try/catch in GET routes when using middleware
Type Safety
- Export response types from GET routes
- Use Zod schemas for validation on both client and server
- Leverage TypeScript inference for better DX
Loading and Error States
- Use
LoadingContentcomponent to handle loading and error states consistently - Pass
loading,error, and children props toLoadingContent - This provides a standardized way to show loading spinners and error messages
Performance
- Use SWR for efficient data fetching and caching
- Call
mutate()after successful mutations to refresh data
File Organization
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


