Page actions
A page action is the server half of a form on a page. The page’s setup file exports action (or a named actions record); a <Form> posts to the same URL; the result comes back as actionData that the page reads with useActionData. It works with and without JavaScript.
For a form that posts to an API route and handles the response in the browser, use useSubmitForm instead. Use a page action when the form belongs to a page and the outcome should re-render that page or redirect.
Export an action
Section titled “Export an action”In the page’s .setup.ts, policy goes in config and the handler is an export, like loader:
import { href } from "@warlock.js/web";import type { PageActionContext, PageConfig } from "@warlock.js/web";import { v } from "@warlock.js/seal";
const contactSchema = v.object({ email: v.string().email(), message: v.string(),});
export const config: PageConfig = { route: { path: "/contact", name: "contact" }, action: { validation: contactSchema },};
export async function action({ request, response }: PageActionContext<typeof contactSchema>) { const { email } = request.validated();
if (email === "blocked@example.com") { return response.forbidden({ message: "This address cannot write to us." }); }
return response.redirect(href("contact"));}import { Form, FieldError, useActionData, useIsSubmitting } from "@warlock.js/web";import type { action } from "./contact.setup";
export default function ContactPage() { const result = useActionData<typeof action>(); const pending = useIsSubmitting();
return ( <Form resetOnSuccess> <input name="email" defaultValue={result?.values?.email} /> <FieldError name="email" /> <textarea name="message" defaultValue={result?.values?.message} /> <FieldError name="message" /> {result?.formErrors.map((message) => ( <p role="alert" key={message}> {message} </p> ))} <button disabled={pending}>Send</button> </Form> );}action and actions are mutually exclusive, and only valid in page modules. config.action requires an action export; config.actions requires actions. Each may declare validation and middleware.
Several forms on one page
Section titled “Several forms on one page”In the setup file, export actions, a plain object of functions, and configure each by key under config.actions. Names match /^[a-z][a-zA-Z0-9]*$/; default is reserved for the unnamed action.
import type { PageActionContext, PageConfig } from "@warlock.js/web";import { v } from "@warlock.js/seal";
const removeSchema = v.object({ itemId: v.string() });
export const config: PageConfig = { route: { path: "/cart", name: "cart" }, actions: { remove: { validation: removeSchema } },};
export const actions = { save: async ({ response }: PageActionContext) => response.redirect("/cart"), remove: async ({ request, response }: PageActionContext<typeof removeSchema>) => { const { itemId } = request.validated();
return response.redirect(`/cart?removed=${itemId}`); },};import { Form, useActionData } from "@warlock.js/web";import type { actions } from "./cart.setup";
export default function CartPage() { const result = useActionData<typeof actions>("remove");
return ( <Form action="remove"> {result?.ok === false && <p role="alert">Could not remove.</p>} <button>Remove</button> </Form> );}The action name travels in the body as the reserved field _action (<Form action="remove"> renders it as a hidden input; a <button name="_action" value="remove"> works too). It is stripped before validation. An invalid or unknown name is refused. useActionData(name) and <FieldError action="remove"> see only that action’s state.
Validation
Section titled “Validation”config.action.validation (or config.actions.<name>.validation) validates the body only, including uploaded files, minus _action. Params and query keep using the page’s config.validation. Inside the action, request.validated() is the typed output. A failure never runs the action and becomes a 422 ActionState.
Validate in the browser with the same schema
Section titled “Validate in the browser with the same schema”For immediate browser feedback, keep the browser-safe rules in a separate
module and import the same Seal schema into both the page action configuration
and an @mongez/react-form form. Seal implements Standard Schema, which the
form accepts through schema.
import { v } from "@warlock.js/seal";
export const contactSchema = v.object({ email: v.string().email(), message: v.string().min(10),});import type { PageActionContext, PageConfig } from "@warlock.js/web";import { contactSchema } from "./contact.schema";
export const config: PageConfig = { route: { path: "/contact", name: "contact" }, action: { validation: contactSchema },};
export async function action({ request, response }: PageActionContext<typeof contactSchema>) { const values = request.validated(); // Handle values, then redirect or return data. return response.redirect("/contact");}import { Form as ReactForm } from "@mongez/react-form";import { useSubmitAction } from "@warlock.js/web/form";import { contactSchema } from "./contact.schema";
export default function ContactPage() { const submit = useSubmitAction<typeof contactSchema>();
return ( <ReactForm method="post" schema={contactSchema} onSubmit={submit.submit}> {/* Your registered controls must be named "email" and "message". */} </ReactForm> );}The action validation is still authoritative. A request that bypasses the browser fails with 422 before the action runs. Keep database uniqueness checks and other server-only rules in server code, or split them from the shared browser-safe schema.
ActionState and useActionData
Section titled “ActionState and useActionData”useActionData<typeof action>() returns undefined before any submit, otherwise:
| Field | Meaning |
|---|---|
action | "default" or the action name |
status, ok | HTTP status and whether it succeeded |
data | The action’s returned value, or a failure helper’s payload |
errors | Field name (dotted) to first message |
formErrors | A helper’s message, plus messages for inputs without a field |
values | Submitted strings, echoed on failure only |
values never includes files, and omits names matching web.forms.redactValues. The default is ["password", "password_confirmation", "*token*", "*secret*"]; * matches any run of characters. Set forms.redactValues in the web config to replace the list.
The same state renders on the server without JS, after a JS swap, and after a JS 422.
Responses
Section titled “Responses”The action’s response is buffered like a loader’s, so header() and cookie() work. Return one of:
response.redirect(url), a redirect (see outcomes below).urlmust be relative orhttp:/https:; any other scheme (javascript:,data:) or a control character throws, so redirecting to anextparameter from the request cannot send script to the browser.- A value: success, and the page’s loaders re-run.
- A failure helper:
badRequest(400),unauthorized(401),forbidden(403),conflict(409),unprocessableEntity(422),tooManyRequests(429),serviceUnavailable(503). Pass an optional{ message, errors };errorsmay be a Seal array or a{ field: message }record.
Outcomes
Section titled “Outcomes”| Action result | No JS (native POST) | With JS (<Form>) |
|---|---|---|
redirect(url) | 303 with Location (301/302 are rewritten to 303) | 204 with x-warlock-redirect; the client navigates there, external URLs hard-navigate |
| Returns data | Loaders re-run, page renders 200 with actionData | Page data swaps in place, same URL |
| Validation / failure helper | Page renders at 422 (or the helper’s status) with actionData including values | 422/4xx body of actionData only; the page tree is not swapped |
| Throws | Error page through the normal boundary | Page stays; routerEvents.onNavigationError fires |
<Form>
Section titled “<Form>”<Form> renders a real <form method="post"> with encType defaulting to multipart/form-data. Props: action (an action name, not a URL), to and params (post to another page’s route by name), query (the query string of the URL it posts to), independent, resetOnSuccess, onSuccess(outcome), onError(actionData), onSettled(outcome), and any other form attribute except action and method. If the navigation runtime is not connected, <Form> leaves the submit to the browser, which posts natively. A submit button whose formaction names another origin is always posted natively: the runtime only enhances same-origin targets.
A submit button’s own formAction and its name/value are honoured on both paths, as the browser does: the button below posts to /runs?mode=inspect with purpose=inspect alongside the form’s fields.
<Form action="recover"> <input name="command" defaultValue={command} /> <button type="submit" name="purpose" value="inspect" formAction="/runs?mode=inspect"> Inspect </button></Form>query names where the submission goes, never what it carries: <Form action="recover" query={{ mode: "inspect" }}> posts to the current page as ?mode=inspect (or to to’s page with that query) and the body is still the form’s fields.
<FieldError name="address[city]"> renders <span role="alert"> when the field has a message; bracket and dotted names are the same field. <Form> sets aria-invalid and aria-describedby on errored inputs. useIsSubmitting() is true while any action submitted from this document is in flight. When the action redirects, the submission ends only once the redirected page’s fresh loader data is on screen, so a form disabled with useIsSubmitting() cannot submit again against the previous page’s data (a stale revision, for example). onSuccess for a redirect fires at the same moment.
Concurrent submissions
Section titled “Concurrent submissions”Ordinary submissions are single-flight: a second submit while one is pending shares the pending request, and a navigation supersedes it. Mark a form independent when it must run while another is pending, such as a Cancel next to a long-running Start:
<Form action="start">…</Form><Form action="cancel" independent> <button type="submit">Cancel</button></Form>An independent submission neither aborts the pending one nor is aborted by it. Its result, a redirect included, is applied only while the user is still on the page it was made on: once a navigation has started or committed, it is dropped. A double submit of the same form with the same fields still shares one request; another form that posts the same action (a Cancel button on each row) always sends its own. Unmounting a form cancels only its own submission. A redirect from an independent action is a navigation like any other and supersedes what is pending.
Using @mongez/react-form? useSubmitAction({ action?, to? }) from @warlock.js/web/form is an onSubmit for its <Form method="post"> and maps errors into the form. Import one of the two Forms under an alias if both appear.
Every page action requires a same-origin Origin or Referer (or one listed in auth.csrf.allowedOrigins), whether or not cookies are present. Otherwise the reply is 403. There is no opt-out. A <Form> is same-origin by construction.
Caching
Section titled “Caching”Actions are never cached. The POST never reads, stores, or honours cache.public and is always private, no-store. Other visitors’ serverCache entries for the page stay stale until you call invalidatePageCache(tags) (from @warlock.js/web/page-cache). The client prefetch cache is cleared after a successful submit.
Uploads
Section titled “Uploads”Multipart is the default encoding, so <input type="file"> works with and without JS. Read files with request.file("avatar") and validate with Seal file rules in config.action.validation. Files are never echoed in values. Body-size limits are core’s.
Redirect after success
Section titled “Redirect after success”A no-JS action that returns data (rather than redirecting) renders at the POST URL, so a browser reload re-submits it. Redirect after success (return response.redirect(...)) unless you deliberately want the result shown in place.
Rate-limit an action per user
Section titled “Rate-limit an action per user”Add core’s rateLimit to the action’s middleware and key it on the signed-in user:
import { middleware } from "@warlock.js/core";
export const config: PageConfig = { route: { path: "/contact", name: "contact" }, action: { middleware: [middleware.rateLimit({ max: 5, duration: 60_000, key: "user", guests: "ip" })], },};The bucket is keyed on request.locals.user.id, so the page needs pageSession() from @warlock.js/auth to populate it. guests decides what happens without a user: "ip" (default) buckets them by IP, "skip" does not limit them. A keyGenerator, if set, wins. Over the limit the action does not run and the reply is a 429.
Middleware
Section titled “Middleware”App, layout, and page middleware run before every action, so a page’s auth guard protects its actions. config.action.middleware runs after those.
Observe every submission outcome
Section titled “Observe every submission outcome”onSettled runs after onSuccess or onError for every enhanced submission,
including network failures and submissions dropped after cancellation, a newer
navigation, or (for independent submissions only) the form unmounting. It still
runs when onSuccess or onError throws. Native browser submissions have no
enhanced outcome.
The exported ActionSubmitOutcome union is:
type | Other fields | Existing callback |
|---|---|---|
success | None | onSuccess(outcome) |
redirected | url: string | onSuccess(outcome) |
error | actionData: Readonly<Record<string, unknown>> | onError(actionData) |
failed | reason: string | None |
dropped | None | None |
<Form onSettled={(outcome) => { if (outcome.type === "failed") setMessage(outcome.reason); if (outcome.type === "dropped") setMessage("Submission cancelled"); }}> <button>Start</button> <button data-independent>Cancel</button></Form>The presence of data-independent on a submit button opts that submission into
independent mode. Other buttons follow the form’s independent prop. The same
form-instance deduplication and unmount cancellation apply to marked buttons.