# Forms in Next.js as a Contract: Zod, fieldErrors, and Unified Rules on Client and Server
A form isn't just a set of fields and buttons—it's a contract between the UI, validation, and data source. When this contract isn't explicitly defined, chaos ensues: the UI thinks the data is valid, the server throws an error, messages appear randomly, and form behavior is unpredictable. The solution? Unify the validation schema, error format, and states using Zod, safeParse, and flatten to ensure identical rules on both client and server.
Why Forms Fall Apart Without a Contract
Many projects start simple: input, state, submit. It works fine for small forms. But as the codebase grows, three critical divergence points emerge:
- Inconsistent validation rules. The client checks string length, but the server also flags banned words or spaces. Users see a green checkmark, but submission fails.
- Mismatched error formats. One time it's a string, next an object, then an array. The UI has to guess the response structure.
- Undefined states. Pending, disabled, success, formError—each component handles them differently, leading to fragmented UX.
Without a single contract, every form follows its own local rules. It doesn't scale.
The Form as a Contract: Key Elements
A form contract must clearly answer these questions:
- What data is considered valid?
- Which errors pertain to specific fields (fieldErrors), and which to the overall operation (formError)?
- How is the pending state handled?
- What gets returned on success?
- Can the server return a response in a different format?
- Are the same rules used on client and server?
These answers should be nailed down before implementing the UI. In Workbench, this approach standardized forms around Zod schemas and a consistent response shape.
One Zod Schema, Two Uses
Instead of duplicating validation logic between client and server, use a single Zod schema everywhere. Example schema for a note:
import { z } from "zod";
export const noteSchema = z.object({
title: z
.string()
.trim()
.min(3, "Enter minimum 3 simvola")
.max(80, "Maximum 80 characters"),
description: z
.string()
.trim()
.max(500, "Maximum 500 characters")
.optional()
.or(z.literal("")),
}).refine(
data => data.title.toLowerCase() !== "test",
{
path: ["title"],
message: "Withlishkom tekhnicheskoe name for zametki",
}
);
export type NoteInput = z.infer<typeof noteSchema>;
This schema defines valid values, transformations (like trim), and custom rules (refine). It becomes the single source of truth for validation.
Safe Validation with safeParse
Using safeParse instead of parse is crucial for forms. parse throws an exception on error—bad for UX, where invalid input is expected. safeParse returns a manageable result:
export function validateNote(input: unknown) {
return noteSchema.safeParse(input);
}
The result includes a success flag and, if needed, an error object. This lets UI and server logic handle the same response type without try/catch.
Unifying Errors with flatten
Zod returns a complex error object. For forms, two levels suffice: field errors and form-wide error. The flatten() method is perfect:
export function formatZodError(error: import("zod").ZodError) {
const flat = error.flatten();
return {
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
Example differences:
- fieldErrors: "Title field cannot be empty", "Description exceeds 500 characters".
- formError: "Note with this slug already exists", "Server temporarily unavailable".
This separation makes UX predictable: users know exactly where to fix input and when to retry or wait.
Unified Contract on Client and Server
On the client, the Zod schema enables early validation and UI state management:
const clientResult = useMemo(() => {
return noteSchema.pick({ title: true }).safeParse({ title });
}, [title]);
const titleError = clientResult.success
? null
: clientResult.error.flatten().fieldErrors.title?.[0] ?? null;
const canSubmit = clientResult.success;
On the server, the same schema acts as the final defense line:
export async function createNoteAction(raw: unknown) {
const parsed = noteSchema.safeParse(raw);
if (!parsed.success) {
const flat = parsed.error.flatten();
return {
ok: false,
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
try {
// save to database
return {
ok: true,
fieldErrors: {},
formError: null,
};
} catch {
return {
ok: false,
fieldErrors: {},
formError: "Not succeeded sokhranit zametku",
};
}
}
One rule, different roles: UX on client, security on server.
Standard Form State Shape
To avoid fragmentation, define a single type for all forms:
export type FormState = {
ok: boolean;
fieldErrors: Record<string, string[] | undefined>;
formError: string | null;
};
This shape covers all states: success, field errors, general errors. Every form in the project returns the same structure—simplifying UI handling.
When to Use React Hook Form
React Hook Form (RHF) is powerful but not a one-size-fits-all. It's justified for forms with:
- Many fields with dependent validation
- Dynamic lists or nested structures
- Blur validation, touched/dirty states
- Custom controls with focus management
- Need for render optimization
For simple forms with 1–2 fields and basic validation, RHF adds unnecessary complexity. If useMemo + Zod derived state solves it—keep it simple.
Shift in Mindset: From UI to Contract
The key change? Stop seeing forms as UI components. A form is a contract defining:
- Valid data schema
- Error response format
- Behavior for states (pending, success, error)
- Client-server responsibility boundaries
With the contract upfront, UI implementation becomes mechanical. Most importantly, every form in the project behaves predictably by following the same agreement.
Key Takeaways
- Use one Zod schema for client and server—eliminates rule divergences.
- Always use
safeParse—it returns manageable results instead of exceptions. - Separate
fieldErrorsandformError—improves UX and simplifies display logic. - Define a standard
FormStateshape—all forms return the same structure. - Add React Hook Form only when truly needed—not for trends, but for complex scenarios.
— Editorial Team
No comments yet.