ProvenPin Install

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
pinned versions6
react-router@8.4.0react@19.3.1react-dom@19.3.1zod@4.6.7tsx@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("react-router-loader-action") (install), or see them on GitHub.

package.json
{
  "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"
  }
}
src/routes.ts
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 };
    },
  }],
}];
test/routes.test.ts
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.

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.