Skip to content

Page actions

A page action is the server half of a form on a page. The page module 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.

Policy goes in config, the handler is an export, like loader:

import { Form, FieldError, useActionData, useIsSubmitting, 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"));
}
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.

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 { Form, useActionData } from "@warlock.js/web";
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}`);
},
};
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.

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.

src/web/contact/contact.schema.ts
import { v } from "@warlock.js/seal";
export const contactSchema = v.object({
email: v.string().email(),
message: v.string().min(10),
});
src/web/contact/contact.page.tsx
import { Form as ReactForm } from "@mongez/react-form";
import { useSubmitAction } from "@warlock.js/web/form";
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");
}
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.

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.

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).
  • 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 }; errors may be a Seal array or a { field: message } record.

| 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> 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), resetOnSuccess, onSuccess(outcome), onError(actionData), and any other form attribute except action and method. If the navigation runtime is not connected, it falls back to a native submit.

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

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.

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.

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.

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.

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.

App, layout, and page middleware run before every action, so a page’s auth guard protects its actions. config.action.middleware runs after those.