Note: This route is intended as a live code example and is only available in a development environment. It will return a 404 error in production.
This document outlines our robust, type-safe, and progressively enhanced form implementation using Next.js Server Actions, Zod for validation, and a custom hook for state management.
This architecture provides a seamless way to handle form submissions with shared validation logic between the client and server. It ensures a great developer experience (DX) and user experience (UX) by centralizing logic and providing instant feedback.
The core idea is to define a single Zod
schema that serves as the single source of truth for validation rules and data types. This schema is used on the client for instant validation and on the server for security and data integrity.
- Single Source of Truth: A
Zod
schema (schema.ts
) defines validation rules and data shapes in one place. - End-to-End Type Safety: The schema's inferred type is used across the client component, the server action, and the business logic, eliminating a whole class of bugs.
- Progressive Enhancement: The form works without JavaScript. Submissions are handled by the server action, and validation is performed on the server.
- Clean, Reusable Logic: The
useFormServerAction
hook encapsulates all the complex form state management, whilecreateFormAction
abstracts away the boilerplate of server-side validation. - Excellent UX: Users get immediate, client-side validation feedback before submitting the form.
This implementation leverages two powerful, industry-standard libraries:
zod
: For declarative, type-safe validation.react-hook-form
&@hookform/resolvers/zod
: For robust and performant form state management on the client.
-
schema.ts
: Defines the Zod schema and exports the schema object and its inferred TypeScript type.export const schema = z.object({ /* ... */ }); export type InputData = z.infer<typeof schema>;
-
ContactForm.tsx
: A Client Component that uses theuseFormServerAction
hook to manage state, register inputs, and handle submissions.const { register, formState: { errors }, submit } = useFormServerAction(schema, submitForm); <form onSubmit={submit}> <input {...register('email')} /> {errors.email && <p>{errors.email.message}</p>} </form>
-
actions.ts
: A Server Action file that uses ourcreateFormAction
factory. This factory takes the schema and a processing function, and returns a fully-validated server action.async function processContactForm(data: InputData) { console.log(data.email, data.message) return { success: true, message: 'Thank you for your message!' } } export const submitForm = createFormAction(schema, processContactForm);
The example below shows a simplified version of a form component to illustrate how the useFormServerAction
hook is used to wire everything together.
It demonstrates how to register fields, display validation errors, show the server's response message, and handle the submission state.
// Simplified Form Component Example
'use client'
import { useFormServerAction } from '@/app/lib/forms/useFormServerAction'
import { schema } from './schema' // 1. Import your Zod schema
import { submitForm } from './actions' // 2. Import your server action
export function SimplifiedContactForm() {
// 3. Call the hook with the schema and action
const {
register,
formState: { errors },
isPending,
state, // This holds the response from the server action
submit,
} = useFormServerAction(schema, submitForm)
return (
// 4. Attach the submit handler
<form onSubmit={submit} noValidate>
<div>
<label htmlFor="email">Email</label>
{/* 5. Register your input */}
<input id="email" type="email" {...register('email')} />
{/* 6. Display validation errors */}
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" {...register('message')} />
{errors.message && <p>{errors.message.message}</p>}
</div>
{/* 7. Display the server response message */}
{state && <p>{state.message}</p>}
{/* 8. Use `isPending` to give user feedback */}
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
Below are the full implementations for the reusable useFormServerAction
hook and the createFormAction
factory.
This custom hook abstracts the client-side form logic. It integrates react-hook-form
with our server action, providing client-side validation against the Zod schema before submission.
// app/lib/forms/useFormServerAction.ts
import { useForm, UseFormReturn, Path } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useEffect, useState } from 'react'
import { type ActionResponse } from './formActions'
export const useFormServerAction = <T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
action: (formData: FormData) => Promise<ActionResponse>,
) => {
const [isPending, setIsPending] = useState<boolean>(false)
const [result, setResult] = useState<ActionResponse | null>(null)
const form: UseFormReturn<z.infer<z.ZodObject<T>>> = useForm<z.infer<z.ZodObject<T>>>({
resolver: zodResolver(schema),
mode: 'onBlur',
})
const {
register: registerForm,
handleSubmit,
formState: { errors },
reset,
clearErrors,
} = form
// Auto-reset form on successful submission
useEffect(() => {
if (result?.success) {
reset()
}
}, [result?.success, reset])
const submit = handleSubmit(async (data: z.infer<z.ZodObject<T>>) => {
setIsPending(true)
setResult(null)
try {
const formData = new FormData()
Object.entries(data).forEach(([key, value]) => {
formData.append(key, String(value))
})
const response: ActionResponse = await action(formData)
setResult(response)
} catch (error) {
console.error(error)
const errorResponse: ActionResponse = {
success: false,
message: 'An unexpected error occurred. Please try again.',
}
setResult(errorResponse)
} finally {
setIsPending(false)
}
})
const register = (name: Path<z.infer<z.ZodObject<T>>>) => ({
...registerForm(name),
onFocus: () => {
if (errors[name]) {
clearErrors(name)
}
},
onChange: () => {
if (errors[name]) {
clearErrors(name)
}
},
})
return {
register,
formState: { errors },
reset,
isPending,
state: result,
submit,
}
}
This server-side factory function takes a Zod schema and a processing function. It returns a standard Next.js Server Action that automatically handles FormData parsing and validation before executing your core business logic.
// app/lib/forms/formActions.ts
import { z } from 'zod'
// Define the response type
export type ActionResponse = {
success: boolean
message: string
errors?: Record<string, string[]>
}
// Generic form action handler
export function createFormAction<T extends z.ZodRawShape>(
schema: z.ZodObject<T> | z.ZodEffects<z.ZodObject<T>>,
processor: (data: z.infer<z.ZodObject<T>>) => Promise<ActionResponse>,
) {
return async (formData: FormData): Promise<ActionResponse> => {
// Extract form data manually to handle both ZodObject and ZodEffects
const rawData: Record<string, unknown> = {}
// Get the shape from the schema (handle both ZodObject and ZodEffects)
const schemaShape = 'shape' in schema ? schema.shape : schema._def.schema.shape
Object.keys(schemaShape).forEach((key) => {
const value = formData.get(key)
if (value !== null) {
rawData[key] = value.toString()
} else {
rawData[key] = ''
}
})
const result = schema.safeParse(rawData)
console.log('Form data parsed:', result)
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
// Filter out undefined values to match Record<string, string[]>
const cleanErrors: Record<string, string[]> = {}
Object.entries(fieldErrors).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0) {
cleanErrors[key] = value
}
})
return {
success: false,
message: 'Invalid input data. Please check your entries.',
errors: cleanErrors,
}
}
try {
const response = await processor(result.data)
return {
success: response.success,
message: response.message,
}
} catch (error) {
console.error('Form processing error:', error)
return {
success: false,
message: 'An error occurred. Please try again later.',
}
}
}
}