Navigated to /docs/getting-started/mutating-data

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

TS
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

PhaseRuntimeResponsibility
Render formBrowser and SSRShow fields, current errors, and pending state.
SubmitBrowserSerialize input and send the non-GET request.
Route actionServerValidate, authenticate, authorize, and commit.
Apply responseBrowserShow field errors, success data, or follow a redirect.
Static-only deploymentUnavailableUse 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.

Checkpoint

Next steps