Navigated to /docs/core/actions-and-forms

Actions, forms, and validation

Handle mutations with route actions, schema validation, explicit response shapes, and server-owned sessions.

Put server mutations in actions

A route action handles non-GET requests in SSR mode. Parse the normalized Fetch Request, authorize the caller, perform the mutation, and return JSON, status, headers, or a redirect.

TS
tsimport { defineAction } from "@tavojs/core/router";

export const action = defineAction(async ({ request }) => {
  const form = await request.formData();
  const name = String(form.get("name") ?? "").trim();
  if (!name) {
    return { status: 400, json: { error: "Project name is required" } };
  }

  return {
    status: 201,
    json: { id: crypto.randomUUID(), name }
  };
});

Validate input before business logic

Tavo.js accepts Standard Schema validators and common parse contracts. defineValidatedAction parses JSON or form input, returns HTTP 400 for invalid input, and passes typed input to the handler.

TS
tsimport { defineValidatedAction } from "@tavojs/core/dev";

type ProjectInput = { name: string };
type ProjectParseResult =
  | { success: true; data: ProjectInput }
  | { success: false; error: { issues: Array<{ message: string; path: string[] }> } };

const projectSchema = {
  safeParse(value: unknown): ProjectParseResult {
    const name =
      value && typeof value === "object"
        ? (value as Record<string, unknown>).name
        : undefined;
    if (typeof name !== "string" || name.trim().length < 2) {
      return {
        success: false,
        error: {
          issues: [{
            message: "Project name must contain at least two characters",
            path: ["name"]
          }]
        }
      };
    }
    return { success: true, data: { name: name.trim() } };
  }
};

export const action = defineValidatedAction(projectSchema, async ({ input }) => {
  return {
    status: 201,
    json: { id: crypto.randomUUID(), name: input.name }
  };
});

Use a safe mutation order

  • Validate the input shape.

  • Authenticate and authorize the request.

  • Apply origin, CSRF, and idempotency rules appropriate to the endpoint.

  • Commit the database or external side effect.

  • Return only safe response data.

Route action contract

  • Route actions handle non-GET requests during SSR route handling. The context is the same portable request context used by loaders.

  • Return a Response directly or an ActionResult. redirect creates a Location response; json serializes a JSON body; status and headers customize the response.

  • Unsafe methods validate browser Origin by default. Set validateOrigin: false only for endpoints with independent authentication such as signed webhooks.

  • When contentType is declared, a mismatched request receives 415 Unsupported Media Type before the handler runs.

TS
tstype ActionResult = {
  body?: BodyInit | null;
  headers?: HeadersInit | Record<string, string | string[]>;
  json?: unknown;
  redirect?: string;
  status?: number;
};

defineAction(handler, {
  contentType?: "form-data" | "json";
  validateOrigin?: boolean;
});

Client action state

  • run aborts an older run and resolves to the final ActionState. Handler failures become error state; run does not rethrow them.

  • abort returns to idle, clears error and completedAt, and preserves existing data. reset aborts and clears the complete state.

  • Subscribe through action.store or a controller's listen/select helpers.

TS
tstype ActionState<TResult> = {
  status: "idle" | "running" | "success" | "error";
  data: TResult | null;
  error: unknown;
  submittedAt: number | null;
  completedAt: number | null;
};

const save = createAction(async ({ input, signal }) => saveProject(input, { signal }));
save.store;        // observable Store<ActionState<TResult>>
save.getState();
await save.run(input);
save.abort();
save.reset();

Form helpers and transport defaults

  • Repeated FormData field names become arrays; single fields remain one FormDataEntryValue.

  • Server forms default to POST, multipart FormData, and same-origin credentials. JSON mode adds application/json unless a content type already exists.

  • The default parser throws for a non-ok response, otherwise returns JSON when declared by the response and text for other content types.

  • body and contentType may be selected by boot mode. A redirected browser response is followed with window.location.assign.

TS
tsformDataToObject(data: FormData): FormValues
createFormAction(handler): FormAction<TResult>
createServerFormAction(url, {
  body?: "form-data" | "json" | ((values, context) => BodyInit);
  contentType?: "form-data" | "json";
  credentials?: RequestCredentials;
  fetch?: typeof fetch;
  headers?: HeadersInit;
  method?: string;
  parseResponse?: (response: Response) => MaybePromise<TResult>;
}): FormAction<TResult>

type FormAction<TResult> = {
  action: Action<FormValues, TResult>;
  store: Store<FormState<TResult>>;
  submit(form: HTMLFormElement | FormData | FormValues): Promise<FormState<TResult>>;
  reset(): void;
};

Validation schemas and failures

validateInput accepts Standard Schema, safeParse or safeParseAsync, and parse or parseAsync contracts. defineValidatedAction reads JSON when Content-Type includes application/json and otherwise converts FormData while preserving repeated fields.

TS
tsconst result = await validateInput(schema, unknownInput);
// { ok: true, value } | { ok: false, issues: [{ message, path? }] }

export const action = defineValidatedAction(schema, async ({ input, request }) => {
  await authorize(request, input);
  return { status: 201, json: await createRecord(input) };
});

// Invalid input response, status 400:
// { error: "validation_failed", issues: [{ message, path? }] }

Look up exact public types

Follow linked API names to their canonical TypeScript declarations and package boundaries.