Next.js 16 server actions + Zod with useActionState-shaped results: working versions (tested 2026-10-15)
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.
passed3 passed · 0 failed2026-10-15 13:23 UTC- Status
- passed
- Last passed
- 2026-10-15 13:23 UTC
- Tests
- 3 / 3
- 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("nextjs-server-action-zod") (install), or see them on GitHub.
{
"name": "nextjs-server-action-zod",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"next": "16.4.1",
"zod": "4.6.7",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2"
}
}
"use server";
import { z } from "zod";
const Contact = z.object({
name: z.string().trim().min(1, "Name is required"),
email: z.email("Enter a valid email"),
message: z.string().trim().min(10, "At least 10 characters").max(2000),
newsletter: z.stringbool().default(false), // checkboxes send "on" or nothing
});
export type ContactState =
| { ok: true; id: string }
| { ok: false; errors: Partial<Record<keyof z.infer<typeof Contact>, string[]>>; values?: Record<string, string> };
export const sent: z.infer<typeof Contact>[] = []; // replace with your DB / email call
// Signature matches useActionState(action, initialState): (prevState, formData)
export async function submitContact(_prev: ContactState | null, formData: FormData): Promise<ContactState> {
const raw = Object.fromEntries(formData) as Record<string, string>;
const parsed = Contact.safeParse(raw);
if (!parsed.success) return { ok: false, errors: z.flattenError(parsed.error).fieldErrors, values: raw };
sent.push(parsed.data);
return { ok: true, id: `msg_${sent.length}` };
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { submitContact, sent } from "../app/contact/actions.ts";
const fd = (o: Record<string, string>) => { const f = new FormData(); for (const [k, v] of Object.entries(o)) f.append(k, v); return f; };
test("valid submission", async () => {
const r = await submitContact(null, fd({ name: " Ada ", email: "ada@example.com", message: "Hello there friend", newsletter: "on" }));
assert.deepEqual(r, { ok: true, id: "msg_1" });
assert.equal(sent[0].name, "Ada");
assert.equal(sent[0].newsletter, true);
});
test("returns field errors and echoes values for the form", async () => {
const r = await submitContact(null, fd({ name: "", email: "nope", message: "short" }));
assert.equal(r.ok, false);
if (r.ok) return;
assert.deepEqual(Object.keys(r.errors).sort(), ["email", "message", "name"]);
assert.equal(r.values?.email, "nope");
});
test("unchecked checkbox defaults to false", async () => {
await submitContact(null, fd({ name: "Bo", email: "bo@example.com", message: "Long enough message" }));
assert.equal(sent.at(-1)!.newsletter, false);
});
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.
React Hook Form + Zod 4 resolver schemas (no DOM)
Share one Zod schema between React Hook Form (via @hookform/resolvers) and the server, with cross-field refinements, tested by calling the resolver 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.