React Router 8 data loaders and actions tested with createStaticHandler: working versions (tested 2026-10-15)
Route loaders and actions with redirects, validation errors and 404 responses in React Router's data APIs, tested offline with createStaticHandler and plain Requests.
passed3 passed · 0 failed2026-10-15 13:25 UTC- Status
- passed
- Last passed
- 2026-10-15 13:25 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("react-router-loader-action") (install), or see them on GitHub.
{
"name": "react-router-loader-action",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"react-router": "8.4.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"zod": "4.6.7"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2"
}
}
import { data, redirect, type RouteObject } from "react-router";
import { z } from "zod";
const notes = new Map<string, { id: string; title: string }>([["1", { id: "1", title: "First" }]]);
const NewNote = z.object({ title: z.string().trim().min(1, "Title required") });
export const routes: RouteObject[] = [{
path: "/notes",
loader: () => ({ notes: [...notes.values()] }),
action: async ({ request }) => {
const parsed = NewNote.safeParse(Object.fromEntries(await request.formData()));
if (!parsed.success) return data({ errors: z.flattenError(parsed.error).fieldErrors }, { status: 400 });
const id = String(notes.size + 1);
notes.set(id, { id, ...parsed.data });
return redirect(`/notes/${id}`);
},
children: [{
path: ":id",
loader: ({ params }) => {
const note = notes.get(params.id!);
if (!note) throw data("Note not found", { status: 404 });
return { note };
},
}],
}];
import { test } from "node:test";
import assert from "node:assert/strict";
import { createStaticHandler } from "react-router";
import { routes } from "../src/routes.ts";
const handler = createStaticHandler(routes);
test("loader data for nested route", async () => {
const ctx: any = await handler.query(new Request("http://app/notes/1"));
assert.equal(ctx.statusCode, 200);
const all = Object.values(ctx.loaderData) as any[];
assert.ok(all.some((d) => d?.note?.title === "First"));
assert.ok(all.some((d) => Array.isArray(d?.notes)));
});
test("missing note is a 404 error boundary", async () => {
const ctx: any = await handler.query(new Request("http://app/notes/999"));
assert.equal(ctx.statusCode, 404);
});
test("action redirects on success and returns 400 with errors", async () => {
const ok = await handler.query(new Request("http://app/notes", { method: "POST", body: new URLSearchParams({ title: "Second" }) }));
assert.ok(ok instanceof Response);
assert.equal(ok.status, 302);
assert.equal(ok.headers.get("location"), "/notes/2");
const bad: any = await handler.query(new Request("http://app/notes", { method: "POST", body: new URLSearchParams({ title: " " }) }));
assert.equal(bad.statusCode, 400);
assert.deepEqual(Object.values(bad.actionData)[0], { errors: { title: ["Title required"] } });
});
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 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.
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.