React Hook Form + Zod 4 resolver schemas (no DOM): working versions (tested 2026-10-15)
Share one Zod schema between React Hook Form (via @hookform/resolvers) and the server, with cross-field refinements, tested by calling the resolver directly.
passed4 passed · 0 failed2026-10-15 13:25 UTC- Status
- passed
- Last passed
- 2026-10-15 13:25 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38741369695
The code
Exactly the files that passed. Your agent can pull them with get_recipe("react-hook-form-zod-resolver") (install), or see them on GitHub.
{
"name": "react-hook-form-zod-resolver",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"react-hook-form": "7.90.0",
"@hookform/resolvers": "5.9.1",
"zod": "4.6.7",
"react": "19.3.1"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2"
}
}
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
export const SignupSchema = z.object({
email: z.email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
confirm: z.string(),
age: z.coerce.number().int().min(18, "Must be 18+"), // inputs give strings; coerce to number
}).refine((d) => d.password === d.confirm, { path: ["confirm"], message: "Passwords do not match" });
export type SignupInput = z.input<typeof SignupSchema>;
export type SignupValues = z.output<typeof SignupSchema>;
// In a component: useForm<SignupInput, unknown, SignupValues>({ resolver: signupResolver })
export const signupResolver = zodResolver(SignupSchema);
import { test } from "node:test";
import assert from "node:assert/strict";
import { signupResolver, SignupSchema } from "../src/schema.ts";
const run = (values: Record<string, unknown>) =>
signupResolver(values as any, undefined, { fields: {}, shouldUseNativeValidation: false } as any);
test("valid values come back coerced", async () => {
const r = await run({ email: "a@example.com", password: "longpass1", confirm: "longpass1", age: "30" });
assert.deepEqual(r.errors, {});
assert.equal((r.values as any).age, 30);
});
test("field and cross-field errors map to RHF errors", async () => {
const r = await run({ email: "nope", password: "short", confirm: "other", age: "12" });
assert.equal((r.errors as any).email.message, "Enter a valid email");
assert.equal((r.errors as any).password.message, "At least 8 characters");
assert.equal((r.errors as any).age.message, "Must be 18+");
});
test("refinement error lands on confirm", async () => {
const r = await run({ email: "a@example.com", password: "longpass1", confirm: "longpass2", age: 20 });
assert.equal((r.errors as any).confirm.message, "Passwords do not match");
});
test("same schema validates on the server", () => {
assert.equal(SignupSchema.safeParse({ email: "a@example.com", password: "longpass1", confirm: "longpass1", age: 40 }).success, true);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Next.js 16 App Router route handler + Zod validation
Validate JSON bodies and search params in a Next.js App Router route handler with Zod, returning typed 400s; tested by calling the handler with NextRequest.
Next.js 16 server actions + Zod with useActionState-shaped results
A 'use server' form action that validates FormData with Zod and returns field errors in the shape useActionState expects, tested by calling the action directly.
React Router 8 data loaders and actions tested with createStaticHandler
Route loaders and actions with redirects, validation errors and 404 responses in React Router's data APIs, tested offline with createStaticHandler and plain Requests.
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
All react-hook-form recipesAll @hookform/resolvers recipesAll Zod recipesAll react recipes