Shared Validation Schemas With Zod for Web App Frontend and API

Shared Validation Schemas With Zod for Web App Frontend and API — cover image

Every web app has forms, and every form has rules. An email must look like an email. A phone number needs the right length. A price cannot be negative. The trouble starts when those rules live in three places: the frontend form, the backend API and the database. Over time they drift apart. The form allows a 100 character name, the API accepts 50, and the database column holds 40. Users see confusing errors, bugs slip through and developers waste hours reconciling rules.

A shared validation schema fixes this by defining the rules once and reusing them everywhere. In the TypeScript world, Zod is a popular library for doing exactly that. This guide explains the idea in plain language, shows how to structure it in a real project, and lists the pitfalls that catch teams out.

The problem with duplicated rules

Imagine a signup form for a SaaS product. The frontend developer adds checks so the email must be valid and the password must have at least eight characters. Later the backend developer writes an API route and adds similar checks, but requires ten characters because security asked for it. Nobody updates the form. Now users type a nine character password, the form says it is fine, the server rejects it, and the user sees a vague "something went wrong" message.

This kind of mismatch causes real business damage. Signup drop-offs rise, support tickets grow, and engineers lose trust in the code. It is also a security risk, because rules that are only enforced on the client are not rules at all.

What a schema gives you

A schema is a description of valid data. With Zod you write something like "an object with a name that is a string between 2 and 60 characters, an email that is a valid address, and an age that is an optional whole number above 17". From that one description you get three things:

Because the type comes from the schema, the compiler warns you whenever the data shape changes and some code is out of date. That alone removes a whole class of bugs.

A real-world style example: an order form

Consider a B2B ordering portal where customers submit a purchase request. The form collects company name, contact email, a list of line items with quantity and unit, and an optional delivery note. The rules are simple but important: quantity must be a positive whole number, the delivery note is capped in length, and at least one line item is required.

The team defines an order schema once in a shared folder. The React form uses it to validate as the user types and to show messages beside each field. The API route uses the same schema to validate the request body before touching the database. A background job that imports orders from a spreadsheet uses it as well, so bad rows are rejected with the same messages. When the business later adds a rule that the delivery note must be under 200 characters, one line changes and all three entry points update together. This is an illustrative scenario, but it matches how shared schemas pay off in practice.

Step by step: set up shared schemas

  1. Create a shared location. In a monorepo, make a small package such as "shared" or "schemas". In a single Next.js project, a top level "schemas" folder is enough. Keep it free of React, database or server code so both sides can import it.
  2. Define schemas by domain. Write one file per concept, such as user, order and invoice. Export both the schema and the inferred type.
  3. Separate input from stored shapes. The data a user submits is often different from what you store. Create an input schema for forms and APIs, and derive other versions with helpers that pick, omit or extend fields.
  4. Connect the form. Use a form library that supports schema resolvers so React Hook Form or a similar tool runs your schema on change and on submit.
  5. Validate on the server. At the edge of every API route, server action or webhook handler, parse the incoming data with the schema and reject anything invalid with a clear error and a 400 status.
  6. Return errors in a stable shape. Use a consistent format that maps field names to messages, so the frontend can display them without special cases.
  7. Add tests for edge cases. Test empty strings, very long input, unusual characters and boundary numbers. Schemas are small and easy to unit test.

Handling tricky cases

Strings that should be numbers

Form inputs and query strings arrive as text. Use coercion helpers so "42" becomes the number 42 before validation, and decide how to treat empty strings. An empty price field should not silently become zero.

Cross-field rules

Some rules involve more than one field, such as "end date must be after start date" or "confirm password must match password". Use refinement functions on the object schema and attach the error to the field the user should fix.

Optional versus nullable

Be explicit. A field that may be missing, a field that may be null and a field that may be an empty string are different situations. Decide which one your database and API use and keep the schema aligned with it.

Localised error messages

If your product serves multiple languages, keep message text separate from rules. Return error codes from the schema and translate them in the interface. This also keeps your API responses stable for mobile clients. For related thinking on API stability, see our guide on API versioning for mobile apps and backward compatibility.

Where validation belongs in your architecture

Think of validation as layers, each with a different job:

A shared schema covers the first two layers well. Do not try to squeeze lookups and database checks into it. Keep the schema pure and fast, and let services handle rules that require context.

The same schema thinking also helps with file uploads, webhooks and third party responses. Anything that crosses a trust boundary deserves validation. If your app accepts files, pair it with the practices in our guide to secure file uploads with presigned URLs.

Key benefits of shared schemas

Pitfalls and how to avoid them

When to consider alternatives

Zod is not the only option. Other TypeScript libraries offer similar ideas, and some API frameworks generate validation from OpenAPI specs or database models. The best choice depends on your stack. If your backend is not TypeScript, you might define rules in a language neutral format such as JSON Schema and generate checks for each side. The principle stays the same: define the contract once, and enforce it at every boundary.

Conclusion

Shared validation schemas are a small investment with a large return. They replace scattered, inconsistent checks with a single contract that your forms, APIs and jobs all respect. Start with your most important form, such as signup or checkout, move its rules into a schema, validate on both sides, and watch the support noise drop.

If you are planning a new product or cleaning up an existing codebase, our team can help you design a maintainable foundation. Learn more about our web development services and how we build reliable, typed web applications for startups and SMEs.

Frequently Asked Questions

What is a shared validation schema?
It is a single definition of what valid data looks like, such as required fields, formats and limits, that both your frontend form and your backend API use, so the rules never drift apart.
Is frontend validation enough to keep my app safe?
No. Browser checks can be bypassed by anyone calling your API directly. The server must always validate input again. Frontend validation exists to give users fast, friendly feedback.
Does Zod only work with TypeScript?
Zod is built for TypeScript and infers types from schemas, which is its biggest benefit. You can use it from plain JavaScript, but you lose much of the type safety value.
Can I share schemas between a Next.js app and a separate Node API?
Yes. Put schemas in a small shared package or folder in a monorepo and import it from both projects. Keep it free of framework code so either side can use it.
How do I show server validation errors in my form?
Return errors in a consistent shape that maps field names to messages, then feed them into your form library so each message appears beside the right input.