Data
Form Actions and Mutations
Process mutating requests, validate form data, redirect after success, and keep Task Tracker writes server-owned.
Actions handle non-GET mutations. In the Task Tracker, creating or updating a task belongs here.
Minimal working example
// app/routes/tasks/new.tsx
import { useActionState } from "react";
import { createRouteAction } from "react-bun-ssr/route";
type NewTaskActionData = { error?: string };
export const action = createRouteAction<NewTaskActionData>();
export default function NewTaskPage() {
const [state, formAction, pending] = useActionState(action, {});
return (
<form action={formAction}>
{state.error ? <p role="alert">{state.error}</p> : null}
<input name="title" placeholder="Ship docs redesign" />
<input name="assignee" placeholder="Owner" />
<button type="submit" disabled={pending}>Create task</button>
</form>
);
}
// app/routes/tasks/new.server.tsx
import { redirect } from "react-bun-ssr";
import type { Action } from "react-bun-ssr/route";
type NewTaskActionData = { error?: string };
export const action: Action = async (ctx) => {
const title = String(ctx.formData?.get("title") ?? "").trim();
const assignee = String(ctx.formData?.get("assignee") ?? "").trim();
ctx.response.headers.set("x-action", "new-task");
if (!title) {
return { error: "Title is required" } satisfies NewTaskActionData;
}
await Promise.resolve({ title, assignee });
ctx.response.cookies.set("flash", "task-created", {
path: "/",
httpOnly: true,
sameSite: "lax",
});
return redirect("/tasks");
};
Same-route validation flow
When an action returns plain data:
- the
useActionState()tuple state updates with the action payload - redirects are handled by framework navigation when the server action returns
redirect() - caught/uncaught action failures reject the submit promise and bubble to boundaries
Page-route document POST requests are no longer the mutation path. Use <form action={formAction}> with useActionState(action, initialState) and action = createRouteAction(...).
Small security helpers
Use same-origin checks and safe redirect sanitization directly in actions:
import { assertSameOriginAction, redirect, sanitizeRedirectTarget } from "react-bun-ssr";
import type { Action } from "react-bun-ssr/route";
export const action: Action = async (ctx) => {
assertSameOriginAction(ctx);
const next = sanitizeRedirectTarget(
String(ctx.formData?.get("next") ?? "/dashboard"),
"/dashboard",
);
return redirect(next);
};
sanitizeRedirectTarget() allows only safe site-relative paths, including query/hash. Unsafe absolute and protocol-relative inputs fall back.
Auth-oriented server companion snippet
Keep the page component client-safe and put Bun-only auth logic in *.server.tsx:
// app/routes/login.server.tsx
import { assertSameOriginAction, redirect, sanitizeRedirectTarget, type Action } from "react-bun-ssr";
import { findUserByEmail, readPasswordHash, saveSession } from "../lib/auth.server";
export const action: Action = async (ctx) => {
assertSameOriginAction(ctx);
const email = String(ctx.formData?.get("email") ?? "").trim().toLowerCase();
const password = String(ctx.formData?.get("password") ?? "");
const next = sanitizeRedirectTarget(String(ctx.formData?.get("next") ?? "/dashboard"));
const user = await findUserByEmail(email);
const hash = user ? await readPasswordHash(user.id) : null;
if (!user || !hash || !(await Bun.password.verify(password, hash))) {
return { error: "Invalid email or password" };
}
const sessionId = Bun.randomUUIDv7();
await saveSession({ sessionId, userId: user.id });
ctx.response.cookies.set("session", sessionId, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: true,
});
return redirect(next);
};
Why redirect-after-success is preferred
A redirect keeps server truth authoritative and simplifies the client transition model:
- submit
- mutate on the server
- redirect to the canonical read route
- let the destination loader produce the fresh state
Rules
- Use actions for writes, not loaders.
- Return plain validation payloads when you want to stay on the same page and read them from
useActionState. - Return
redirect()when the mutation should land on another route. - Stage cookies/headers through
ctx.responseand let the framework commit them onto the final response. - Throw typed route errors when validation must bubble to a catch boundary.
- Prefer
<form action={formAction}>over<form method="post">for page mutations.
Related APIs
ActionActionContextActionResultassertSameOriginActioncreateRouteActionredirectsanitizeRedirectTargetuseRouteAction
Next step
Read Error Handling to model validation and exceptional states precisely.