Working with Forms
Build type-safe forms with react-hook-form, Zod validation, and server action integration in your Next.js Prisma kit.
Build forms with automatic validation, type-safe server action integration, and loading states - using react-hook-form, Zod schemas, and next-safe-action.
The Next.js Prisma kit form stack gives you client-side validation, server-side validation, and error handling with minimal boilerplate. You define a Zod schema once and use it everywhere: in your form for client validation, in your server action for server validation, and TypeScript infers the types automatically.
The form pattern in the Next.js Prisma kit combines react-hook-form for state management, Zod for validation, @kit/ui/form for styled components, and next-safe-action for type-safe server mutations.
- Use this pattern when: building any form that submits data to the server user input, settings, CRUD operations.
- Use a simpler approach when: the form is purely client-side (search filters, local state) with no server action.
Form Stack
| Library | Purpose |
|---|---|
react-hook-form | Form state management |
@kit/ui/form | Shadcn form components |
Zod | Schema validation (shared) |
next-safe-action | Type-safe server actions |
The Complete Form Pattern
1. Define Zod Schema
The first step is to define the Zod schema for the form. This schema will be used to validate the form data on the server and client sides.
File: _lib/schemas/feature.schema.ts
import { z } from 'zod';export const createProjectSchema = z.object({ name: z.string().min(3, 'Name must be at least 3 characters'), description: z.string().optional(),});export type CreateProjectInput = z.output<typeof createProjectSchema>;2. Create Server Action
Next, we need to create the server action that will be used to create the project. This action will be used to validate the form data on the server side and insert the data into the database.
The convention I recommend is to create a server action for each feature. This will make the code more modular and easier to maintain - and we can name the action after the feature. Make sure to suffix each action function with the word "Action". This makes it explicit that the function is a server action.
File: _lib/actions/feature-server-actions.ts
'use server';import { revalidatePath } from 'next/cache';import { authenticatedActionClient } from '@kit/action-middleware';import { getActiveOrganizationId } from '@kit/better-auth/context';import { db } from '@kit/database';import { getLogger } from '@kit/shared/logger';import { generateId } from '@kit/shared/uuid';import { createProjectSchema } from '../schemas/feature.schema';export const createProjectAction = authenticatedActionClient .inputSchema(createProjectSchema) .action(async ({ parsedInput: data, ctx }) => { // ctx.user is available from authenticated middleware const userId = ctx.user.id; const organizationId = await getActiveOrganizationId(); const logger = (await getLogger()).child({ userId, projectName: data.name, organizationId, }); logger.info('User attempting to create project...'); if (!organizationId) { logger.error('No active organization found'); throw new Error('No active organization found'); } const project = await db.project.create({ data: { // uuidv7 is a good choice for the id because it's a unique identifier and it's easy to generate. alternatively, you can use the built-in `gen_random_uuid()` function to generate a random UUID when defining the schema. id: generateId(), name: data.name, description: data.description, organizationId, }, }); logger.info({ projectId: project.id, }, 'Project created successfully'); // make sure to revalidate the projects page so that the new project is displayed immediately. revalidatePath('/projects'); return { projectId: project.id, }; });3. Build Form Component
Next, we need to build the form component that will be used to create the project. This component will be used to display the form and handle the form submission.
File: _components/feature-form.tsx
'use client';import { zodResolver } from '@hookform/resolvers/zod';import { useAction } from 'next-safe-action/hooks';import { useForm } from 'react-hook-form';import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from '@kit/ui/form';import { Input } from '@kit/ui/input';import { Textarea } from '@kit/ui/textarea';import { Button } from '@kit/ui/button';import { toast } from '@kit/ui/sonner';import { createProjectAction } from '../_lib/actions/feature-server-actions';import { createProjectSchema, type CreateProjectInput } from '../_lib/schemas/feature.schema';export function ProjectForm() { const form = useForm({ resolver: zodResolver(createProjectSchema), defaultValues: { name: '', description: '', }, }); const { executeAsync, status } = useAction(createProjectAction); const isPending = status === 'executing'; const onSubmit = async (data: CreateProjectInput) => { return toast .promise( executeAsync(data).then((response) => { // always check for errors and validation errors. if (response.serverError || response.validationErrors) { throw new Error( response.serverError || 'An unknown error occurred', ); } // reset the form after a successful submission. form.reset(); }), { loading: 'Creating project...', success: 'Project created successfully!', error: 'Failed to create project', }, ) .unwrap(); }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Project Name</FormLabel> <FormControl render={ <Input {...field} placeholder="Enter project name" disabled={isPending} data-testid="project-name-input" /> } /> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="description" render={({ field }) => ( <FormItem> <FormLabel>Description (Optional)</FormLabel> <FormControl render={ <Textarea {...field} rows={4} placeholder="Describe your project" disabled={isPending} /> } /> <FormMessage /> </FormItem> )} /> <Button type="submit" disabled={isPending} data-testid="submit-button"> {isPending ? 'Creating...' : 'Create Project'} </Button> </form> </Form> );}Note: adding data-testid attributes to the form fields is a good practice for end-to-end testing. It's going to be very helpful to have these attributes when writing tests.
Server Actions with next-safe-action
Authenticated Action Client
All server actions use the authenticated action client which ensures the user is logged in:
'use server';import { authenticatedActionClient } from '@kit/action-middleware';import { z } from 'zod';const mySchema = z.object({ name: z.string().min(3),});export const myAction = authenticatedActionClient .inputSchema(mySchema) .action(async ({ parsedInput, ctx }) => { // parsedInput: validated input from schema // ctx.user: current authenticated user const user = ctx.user; // { id, email, ... } return { success: true }; });Key Points:
- Import from
@kit/action-middlewarenot@kit/next/actions ctx.useris available from middleware
Action with Permission Checks
Use the withFeaturePermission middleware for declarative permission checks:
'use server';import { z } from 'zod';import { authenticatedActionClient } from '@kit/action-middleware';import { withFeaturePermission } from '@kit/action-middleware';const schema = z.object({ name: z.string(),});export const protectedAction = authenticatedActionClient .use(withFeaturePermission({ project: ['create'] })) .inputSchema(schema) .action(async ({ parsedInput, ctx }) => { // Permission already verified by middleware // ctx.organizationId and ctx.role are available return { success: true }; });Action with Logging
Logging is super important for debugging and monitoring.
It's a good practice to log the user id, the action name, the input data (but careful to sensitive information), and each step of an async operation, (before executing, after executing, and in case of errors).
'use server';import { authenticatedActionClient } from '@kit/action-middleware';import { getLogger } from '@kit/shared/logger';export const myAction = authenticatedActionClient .inputSchema(mySchema) .action(async ({ parsedInput, ctx }) => { const logger = (await getLogger()).child({ userId: ctx.user.id, action: 'my-action', }); logger.info('Action started'); try { // Do work logger.info('Action completed successfully'); return { success: true }; } catch (error) { logger.error('Action failed', { error }); throw error; } });Form Patterns
Pattern 1: With toast.promise (Recommended)
Best for most forms - provides automatic toast notifications:
'use client';import { useAction } from 'next-safe-action/hooks';import { toast } from '@kit/ui/sonner';export function MyForm() { const form = useForm({ /* ... */ }); const { executeAsync, status } = useAction(myAction); const isPending = status === 'executing'; const onSubmit = async (data) => { return toast .promise( executeAsync(data).then((response) => { // Check for errors if (response.serverError || response.validationErrors) { throw new Error( response.serverError || 'Validation failed' ); } // Success form.reset(); }), { loading: 'Saving...', success: 'Saved successfully!', error: 'Failed to save', }, ) .unwrap(); }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> {/* Form fields */} <Button disabled={isPending}> {isPending ? 'Saving...' : 'Save'} </Button> </form> </Form> );}Pattern 2: With execute and callbacks
Use when you need custom success/error handling:
'use client';import { useAction } from 'next-safe-action/hooks';export function MyForm({ onSuccess }: { onSuccess: () => void }) { const form = useForm({ /* ... */ }); const { execute, status, result } = useAction(myAction, { onSettled: onSuccess, }); const isPending = status === 'executing'; const errorData = result.data as | { success: false; error: string } | undefined; const error = result.serverError || errorData?.error; return ( <Form {...form}> <form onSubmit={form.handleSubmit(execute)}> {error && <Alert variant="destructive">{error}</Alert>} {/* Form fields */} <Button disabled={isPending}> {isPending ? 'Saving...' : 'Save'} </Button> </form> </Form> );}Form Dialogs
Combining forms with dialogs (the actual pattern used in the codebase):
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@kit/ui/dialog';import { useState } from 'react';import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from '@kit/ui/form';import { Input } from '@kit/ui/input';import { Button } from '@kit/ui/button';import { toast } from '@kit/ui/sonner';import { createProjectSchema } from '../_lib/schemas/project.schema';import { createProjectAction } from '../_lib/actions/project-server-actions';import { useForm } from 'react-hook-form';import { zodResolver } from '@hookform/resolvers/zod';import { useAction } from 'next-safe-action/hooks';export function CreateProjectDialog({ open, onOpenChange,}: { open: boolean; onOpenChange: (open: boolean) => void;}) { return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent onEscapeKeyDown={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > <DialogHeader> <DialogTitle>Create New Project</DialogTitle> <DialogDescription> Enter the details for your new project. </DialogDescription> </DialogHeader> <CreateProjectForm onOpenChange={onOpenChange} /> </DialogContent> </Dialog> );}function CreateProjectForm({ onOpenChange,}: { onOpenChange: (open: boolean) => void;}) { const form = useForm({ resolver: zodResolver(createProjectSchema), defaultValues: { name: '', }, }); const { executeAsync, status } = useAction(createProjectAction); const onSubmit = async (data) => { return toast .promise( executeAsync(data).then((response) => { if (response.serverError || response.validationErrors) { throw new Error( response.serverError || 'An unknown error occurred', ); } form.reset(); onOpenChange(false); }), { loading: 'Creating project...', success: 'Project created successfully!', error: 'Failed to create project', }, ) .unwrap(); }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Project Name</FormLabel> <FormControl render={ <Input {...field} disabled={status === 'executing'} data-testid="project-name-input" /> } /> <FormMessage /> </FormItem> )} /> <div className="flex justify-end gap-2"> <DialogClose asChild> <Button type="button" variant="outline" disabled={status === 'executing'} onClick={() => form.reset()} > Cancel </Button> </DialogClose> <Button type="submit" disabled={status === 'executing'} data-testid="submit-button" > {status === 'executing' ? 'Creating...' : 'Create'} </Button> </div> </form> </Form> );}It's a good practice to declare the form component outside the dialog component. This will make the code more readable and maintainable - but also make sure to instantiate the form component only when the dialog gets opened.
Form Best Practices
1. Always Use Zod Schemas
// ✅ Good - shared schema for client and serverexport const schema = z.object({ email: z.string().email(),});// Form uses schemaconst form = useForm({ resolver: zodResolver(schema) });// Server action uses same schemaexport const action = authenticatedActionClient .inputSchema(schema) .action(...);2. Add data-testid for E2E Tests
<Input {...field} data-testid="email-input" /><Button type="submit" data-testid="submit-button"> Submit</Button>3. Use toast.promise for Better UX
return toast .promise( executeAsync(data).then(/* ... */), { loading: 'Processing...', success: 'Success!', error: 'Failed', }, ) .unwrap();4. Show Loading States
const { status } = useAction(myAction);const isPending = status === 'executing';<Button disabled={isPending}> {isPending ? 'Saving...' : 'Save'}</Button>5. Always Validate on Server
'use server';export const myAction = authenticatedActionClient .inputSchema(mySchema) // Server-side validation .action(async ({ parsedInput }) => { // Input is already validated and typed });6. Use FormMessage for Errors
<FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl render={ <Input {...field} /> } /> <FormMessage /> {/* Shows validation errors */} </FormItem> )}/>Common Pitfalls
- Mismatched schemas: The form resolver and server action must use the same Zod schema. Different schemas cause type mismatches and validation gaps.
- Forgetting
isPending: Always disable the submit button while the action is executing. Otherwise users can submit multiple times. - Not resetting on success: Call
form.reset()after successful submission to clear the form state. - Catching all errors: Check for
serverErrorandvalidationErrorsseparately. They have different shapes and meanings. - Missing
data-testid: Add test IDs to inputs and buttons for reliable E2E testing. - Instantiating forms in dialogs: Define the form component outside the Dialog. This prevents re-mounting on every dialog open.
Frequently Asked Questions
How do I handle server validation errors?
Should I validate on client or server?
How do I pre-fill form values?
Why use toast.promise instead of manual toasts?
How do I add a confirmation dialog before submit?
Related
- Development Guide Overview - Full development patterns guide
- Server Actions - Type-safe mutations with authentication
- Adding Features - Step-by-step feature tutorial
Next: Database Operations →