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

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

TSXCreate: 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

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 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.

Next steps