Back to Home

Forms in Next.js as a contract: Zod and unified rules

The article explains how to treat forms in Next.js not as UI components, but as contracts between the interface, validation, and server. Using Zod, safeParse and flatten allows unifying rules, error format and states, ensuring predictable UX and no discrepancies between client and server.

How to turn forms in Next.js into a reliable contract using Zod
Advertisement 728x90

# 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.

Google AdInline article slot

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:

Google AdInline article slot
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.

Google AdInline article slot

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 fieldErrors and formError—improves UX and simplifies display logic.
  • Define a standard FormState shape—all forms return the same structure.
  • Add React Hook Form only when truly needed—not for trends, but for complex scenarios.

— Editorial Team

Advertisement 728x90

Read Next