ProvenPin Install

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
pinned versions6
next@16.4.1zod@4.6.7react@19.3.1react-dom@19.3.1tsx@4.23.15@types/node@24.19.2
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
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.

package.json
{
  "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"
  }
}
app/contact/actions.ts
"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}` };
}
test/actions.test.ts
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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.