How to Implement Form Validation That Runs on the Server Too

    6 min read3 approaches compared

    Client-side form validation is a user experience feature. It is not a security control, it is not a data-integrity guarantee, and any endpoint that trusts it will eventually receive whatever the sender felt like typing into curl.

    The practical goal is therefore a single schema definition that runs in both places — instant feedback in the browser, enforcement on the server, one source of truth so they can't drift. Everything else is timing: when to show an error so it helps rather than nags.

    One schema, two runtimes

    Define the rules once and run them client-side for feedback and server-side for enforcement. Duplicating the rules in two languages guarantees they diverge, usually in the direction that lets bad data through.

    • Write the schema in shared code both sides import. With a TypeScript backend this is free; otherwise generate one from the other, or use JSON Schema.
    • Validate on blur for the first error, then on change once a field has been touched. Validating on every keystroke from empty means an error appears before typing finishes.
    • Always re-validate on the server, on every endpoint, including ones only your own UI calls.
    • Return field-level errors from the server in the same shape the client already renders, so a server rejection displays like any other error.
    • Never lose the user's input on a failed submit. Repopulating a cleared form is one of the fastest ways to lose a signup.

    The approaches

    Schema validation with Zod or Valibot

    // best for: TypeScript stacks, which is where sharing the schema is genuinely free.

    • TypeScript
    • React
    • Next.js
    • SvelteKit
    • NestJS
    • Express.js

    A Zod schema is both a runtime validator and a static type, so `z.infer` gives you the TypeScript type without a second declaration. Put the schema in shared code and both the form and the API handler import the same object — the rules cannot drift because there is only one copy.

    The distinction worth internalizing is between the input and output type. Coercion and transforms mean the parsed result may differ from what was submitted — a date arrives as a string and comes out as a `Date`. Typing the form against the output type when it holds the input is a common source of confusing type errors.

    Use `safeParse` at API boundaries rather than `parse`. It returns a result object instead of throwing, which makes returning structured field errors straightforward.

    typescript
    // shared/schema.ts — imported by BOTH the form and the API handler.
    export const signupSchema = z.object({
      email: z.string().email("Enter a valid email address"),
      password: z.string().min(12, "Use at least 12 characters"),
      age: z.coerce.number().int().min(13, "You must be 13 or older"),
    });
    
    // The form sends strings; coerce turns age into a number.
    // These are two different types and conflating them is a
    // common source of confusing errors.
    export type SignupInput  = z.input<typeof signupSchema>;   // age: unknown
    export type SignupOutput = z.output<typeof signupSchema>;  // age: number

    The gotcha

    `z.string().optional()` does not accept an empty string, and HTML inputs submit `""` rather than undefined for untouched optional fields. Validation then fails on a field the user correctly left blank. Use `.optional().or(z.literal(""))`, or normalize empty strings to undefined before parsing.

    Form libraries (React Hook Form, Formik, VeeValidate)

    // best for: Anything beyond a two-field form, especially with dynamic or nested fields.

    • React
    • Vue.js
    • Next.js
    • Nuxt
    • Angular

    React Hook Form is the current default in React, largely because it keeps inputs uncontrolled and subscribes to changes per field. A large form doesn't re-render the whole tree on every keystroke, which matters once you have thirty inputs.

    It pairs with schema validators through resolvers, so the Zod schema drives validation and the library handles touched state, error display, and submission. That combination — schema for rules, library for state — is the setup most teams converge on.

    The genuinely valuable feature is `formState`. Knowing whether a field is touched, dirty, or currently validating is what lets you show errors at the right moment instead of immediately.

    The gotcha

    Registering a field conditionally without unregistering it leaves stale values and stale errors in form state when the field is hidden. A form that fails validation because of a field the user cannot see is nearly impossible for them to diagnose. Use `shouldUnregister`, or clear the value when hiding it.

    Server-side and framework-native validation

    // best for: Server-rendered apps, and the enforcement layer for every application regardless.

    • Django
    • Ruby on Rails
    • Laravel
    • FastAPI
    • NestJS

    Django forms, Rails' Active Record validations, Laravel's form requests, and FastAPI's Pydantic models all validate server-side and render field errors back into the template. For a server-rendered app this can genuinely be the whole implementation, with no client-side JavaScript at all — the form posts, fails, and re-renders with errors and the user's input intact.

    Even with a JavaScript frontend, this layer is mandatory. It's the only one that's actually enforced.

    Where it needs care is matching database constraints to validation rules. An application-level uniqueness check has a race: two simultaneous signups both pass the check and one hits a database constraint violation. You want both — the validation for the friendly message, the constraint for correctness — and you must catch the constraint error and present it as a field error rather than a 500.

    The gotcha

    Uniqueness validated only in application code is a race condition, not a guarantee. Two concurrent requests both see the email as available. You need the database unique constraint as the real enforcement, and code that catches the violation and turns it into 'that email is already registered'.

    Common pitfalls

    Trusting client-side validation

    Anyone can post directly to your endpoint. Every rule that matters — length limits, allowed values, ownership, numeric ranges — must be re-checked server-side. Client validation exists to be helpful, not to be trusted.

    Validating on every keystroke from the start

    Showing 'invalid email' after the user types one character is hostile. Validate on blur for the first pass, then on change once the field has been touched, so errors clear as they're fixed but never appear mid-typing.

    Over-strict email regex

    Most hand-written email patterns reject valid addresses — plus signs, new TLDs, subdomains. Check for an `@` with something either side, then verify by sending mail. Deliverability is the only real test.

    Errors that don't say what to do

    'Invalid input' tells the user nothing. Name the field, the rule, and the fix: 'Password must be at least 12 characters.' Associate it with the input via `aria-describedby` so screen readers announce it.

    Questions people actually ask

    Do I still need server validation if the client validates?

    Always. Client-side validation is a convenience for cooperative users. Your API is reachable by anything that can make an HTTP request, and it will be.

    When should validation errors appear?

    On blur for the first error on a field, then on change once it's been touched. This gives feedback after an attempt is complete and lets errors clear live as the user fixes them.

    How do I share one schema between frontend and backend?

    In a TypeScript monorepo, put the schema in a shared package both import. Across languages, define it as JSON Schema and generate validators for each side, or have the backend expose its schema and generate client types from it.

    Need someone who's done this before?

    Browse vetted developers who work with these tools day to day.

    Related guides