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.
Next.js (App Router)
Section titled “Next.js (App Router)”A route handler already receives/returns the standard Request/Response
— no framework glue needed:
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" }, });}Express
Section titled “Express”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[]});Standard Schema (Valibot, ArkType, …)
Section titled “Standard Schema (Valibot, ArkType, …)”@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);Why no applyServerErrors() helper
Section titled “Why no applyServerErrors() helper”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().