Mutating data
Handle server changes through validated, authorized route actions with explicit pending and failure states.
Put server mutations in route actions
A route action handles non-GET requests in SSR mode. It receives a standard Request, so the handler can read JSON or form data, authorize the caller, perform the change, and return JSON, status, headers, or a redirect.
Create src/pages/projects/new.tsx
tsimport {
createServerFormAction,
createTavo,
TavoController,
} from "@tavojs/core";
import { defineValidatedAction } from "@tavojs/core/dev";
import {
Button,
Field,
FormControl,
Page,
Stack,
Text,
TextInput,
} from "@tavojs/ui";
type ProjectInput = { name: string };
const projectSchema = {
parse(input: unknown): ProjectInput {
const name = String((input as { name?: unknown }).name ?? "").trim();
if (!name) throw new Error("Project name is required");
return { name };
},
};
export const action = defineValidatedAction(
projectSchema,
async ({ input, request }) => {
const { requireProjectPermission, saveProject } =
await import("./project-store.server");
await requireProjectPermission(request);
const project = await saveProject(input);
return { redirect: `/projects/${project.id}` };
},
);
type ProjectFormState = { pending: boolean; error: string };
class ProjectFormController extends TavoController {
formAction = createServerFormAction("/projects/new");
async submit(event: Event) {
event.preventDefault();
this.model.patch({ pending: true, error: "" });
const result = await this.formAction.submit(
event.currentTarget as HTMLFormElement,
);
this.model.patch({
pending: false,
error:
result.status === "error"
? result.error instanceof Error
? result.error.message
: "Project could not be created"
: "",
});
}
}
export default createTavo<{}, ProjectFormState, ProjectFormController>({
model: () => ({ pending: false, error: "" }),
controller: ProjectFormController,
view: ({ state, controller }) => (
<Page>
<Stack gap="md">
<Text as="h1" variant="h1">
New project
</Text>
<FormControl
method="post"
onSubmit={(event: Event) => void controller?.submit(event)}
>
<Field label="Project name" error={state.error} required>
<TextInput id="project-name" name="name" required />
</Field>
<Button type="submit" disabled={state.pending}>
{state.pending ? "Creating…" : "Create project"}
</Button>
</FormControl>
{state.error ? (
<Text role="alert" tone="danger">
{state.error}
</Text>
) : null}
</Stack>
</Page>
),
});
Separate browser and server responsibilities
| Phase | Runtime | Responsibility |
|---|---|---|
| Render form | Browser and SSR | Show fields, current errors, and pending state. |
| Submit | Browser | Serialize input and send the non-GET request. |
| Route action | Server | Validate, authenticate, authorize, and commit. |
| Apply response | Browser | Show field errors, success data, or follow a redirect. |
| Static-only deployment | Unavailable | Use a server runtime or external API for mutations. |
Validate before business logic
defineValidatedAction accepts Standard Schema and common parse-compatible validators. Invalid input receives a structured 400 response before the mutation handler runs.
Keep the mutation order predictable
Parse and validate the input shape.
Authenticate the request and authorize the specific resource operation.
Apply origin, CSRF, and idempotency rules appropriate to the endpoint.
Commit the database or external side effect.
Return only safe data, an explicit error shape, or a same-origin redirect.
Show pending and expected failures
Use createServerFormAction when a controller should own browser submission state for a route action. Disable duplicate submission while pending, associate validation messages with their fields, and announce the result without moving focus unexpectedly.