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.
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.
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
contentTypeis declared, a mismatched request receives 415 Unsupported Media Type before the handler runs.
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.storeor a controller's listen/select helpers.
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
FormDatafield names become arrays; single fields remain oneFormDataEntryValue.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
contentTypemay be selected by boot mode. A redirected browser response is followed withwindow.location.assign.
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.
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.