Mutating data
Handle server changes through validated, authorized route actions with explicit pending and failure states.
Put server mutations in route actions
First create src/server/session.ts and src/server/projects.ts from the Server routes guide. This example imports those files and redirects successful submissions to the project-detail route from Pages and layouts. Run npm run dev:ssr so the action endpoint exists. 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
tsximport {
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 =
input !== null && typeof input === "object" && "name" in input
? input.name
: undefined;
if (typeof name !== "string" || !name.trim()) {
throw new Error("Project name is required");
}
if (name.trim().length > 100) {
throw new Error("Project name must be 100 characters or fewer");
}
return { name: name.trim() };
},
};
export const action = defineValidatedAction(
projectSchema,
async ({ input, request }) => {
if (request.method !== "POST") {
return { status: 405, headers: { Allow: "POST" } };
}
const { canCreateProject, saveProject } =
await import("../../server/projects");
if (!(await canCreateProject(request))) {
return { status: 401, json: { error: "authentication_required" } };
}
const project = await saveProject(input);
return { redirect: `/projects/${project.id}` };
},
);
type ProjectFormState = { pending: boolean; error: string };
class ProjectFormController extends TavoController {
formAction = createServerFormAction<{ message: string }>("/projects/new", {
async parseResponse(response) {
if (response.status === 400) {
const result = (await response.json()) as {
error?: string;
issues?: Array<{ message: string }>;
};
if (result.error === "validation_failed") {
return {
message:
result.issues?.map((issue) => issue.message).join(" ") ||
"Check the project name.",
};
}
}
if (response.status === 401) {
return { message: "Sign in before creating a project." };
}
if (!response.ok) throw new Error("Project could not be created");
// The helper follows an action redirect with a document navigation.
return { message: "" };
},
});
onInit() {
this.cleanup(() => this.formAction.action.abort());
}
async submit(event: Event) {
event.preventDefault();
if (this.model.getState().pending) return;
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"
? "Project could not be created. Check your connection and try again."
: (result.data?.message ?? ""),
});
}
}
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"
action="/projects/new"
aria-busy={state.pending}
onSubmit={(event: Event) => void controller?.submit(event)}
>
<Field label="Project name" error={state.error} required>
<TextInput id="project-name" name="name" maxLength={100} 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>
),
});
Learn more
Create the example server modules — The demo session and project store are required imports, with explicit development-only limitations.
Create the redirect destination — Add src/pages/projects/[id].tsx before submitting the form.
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 400 response shaped as { error: "validation_failed", issues: [{ message, path? }] } before the mutation handler runs. The example rejects missing, repeated, non-string, blank, and overly long names rather than coercing arbitrary input into a string.
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
createServerFormAction throws a generic error for a non-2xx response by default. Supply parseResponse to turn known 400 validation issues and 401 authentication failures into useful form messages, as the example does. The helper follows a server redirect with a full document navigation. Use it 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.