Skip to content

Server validation

Anything that skips your form — curl, a different client, someone hostile — reaches your handler with no validation at all. Running the same rules on the server closes that.

serverValidate(schema, payload) runs the same Zod or Standard Schema definition your form already uses, and returns errors in the exact MdyFormError[] shape a form.submit() action returns — so one schema feeds both the client’s inline errors and the server’s rejection, and one error shape flows through both.

import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Invalid email"),
age: z.number().min(18, "18+ only"),
});

On the client, createZodForm(signupSchema) (or @modyra/angular/zod’s mdyFormFromSchema) drives the same rules keystroke-by-keystroke. On the server, every route below runs the identical schema through serverValidate and answers 422 with the same error paths/messages the client would have shown, or 200 once the payload is clean.

The three examples below are the literal, tested source in docs/examples/server-validation/npm run test:guides runs all three against the built @modyra/zod package, so nothing here is an unverified snippet.

A route handler already receives/returns the standard Request/Response — no framework glue needed:

app/api/signup/route.ts
import { serverValidate } from "@modyra/zod";
import { signupSchema } from "./schema";
export async function POST(request: Request) {
const payload = await request.json();
const errors = serverValidate(signupSchema, payload);
if (errors.length > 0) {
return new Response(JSON.stringify({ errors }), {
status: 422,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
import express from "express";
import { serverValidate } from "@modyra/zod";
import { signupSchema } from "./schema";
const app = express();
app.use(express.json());
app.post("/api/signup", (req, res) => {
const errors = serverValidate(signupSchema, req.body);
if (errors.length > 0) {
res.status(422).json({ errors });
return;
}
res.json({ ok: true });
});
import { Hono } from "hono";
import { serverValidate } from "@modyra/zod";
import { signupSchema } from "./schema";
const app = new Hono();
app.post("/api/signup", async (c) => {
const payload = await c.req.json();
const errors = serverValidate(signupSchema, payload);
if (errors.length > 0) return c.json({ errors }, 422);
return c.json({ ok: true });
});

Feeding the errors back into the client form

Section titled “Feeding the errors back into the client form”

serverValidate’s return value is already what form.submit() expects, so wiring a rejected response back into the form needs no extra glue:

await form.submit(async (value) => {
const res = await fetch("/api/signup", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(value),
});
if (res.status === 422) return (await res.json()).errors; // MdyFormError[]
});

@modyra/standard-schema exports the same function, async — the Standard Schema spec allows ~standard.validate to return a Promise, and unlike the library’s sync form-level validator (which rejects async schemas up front — see Schema adapters), a server call is free to await one:

import { serverValidate } from "@modyra/standard-schema";
const errors = await serverValidate(signupSchema, payload);

form.submit(action) already accepts any action that returns MdyFormError[] — including one that calls serverValidate directly against a local schema instead of a network round-trip (useful for a same-process SSR action). There is no separate glue method: submit() is already the single entry point that stores returned errors and surfaces them through errorsFor().