FUNSTACK Router can intercept <form> submissions and run an action function on the client before navigation occurs. This guide explains how actions work, when to use them, and important considerations for progressive enhancement.
Important: Progressive Enhancement
A <form method="post"> should work even before JavaScript has loaded. The browser natively submits POST forms to the server, so your server must be prepared to handle these requests. The router’s action function is a client-side shortcut that runs only after hydration — it does not replace server-side form handling.
If your server cannot handle the POST request, users on slow connections, users with JavaScript disabled, or users who submit the form before hydration completes will experience a broken form. Always ensure your server handles POST submissions for the same URL as a baseline.
When a <form method="post"> is submitted, the router matches the form’s destination URL against the route definitions. If a matched route defines an action, the router intercepts the submission via the Navigation API instead of letting the browser send it to the server. The flow is:
method="post"navigate event with formDataactionaction function runs with the form data wrapped in a Requestloader as actionResultIf the matched route does not define an action, the router does not intercept the submission and the browser sends it to the server as a normal POST request.
Add an action function to your route definition. It receives an ActionArgs object with the route params, a Request, and an AbortSignal:
import { route } from "@funstack/router";
const editRoute = route({
path: "/posts/:postId/edit",
action: async ({ params, request, signal }) => {
const formData = await request.formData();
const title = formData.get("title") as string;
const body = formData.get("body") as string;
const res = await fetch(`/api/posts/${params.postId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body }),
signal,
});
return res.json();
},
loader: async ({ params, actionResult, signal }) => {
// After a successful action, actionResult contains
// the return value. On normal navigations it is undefined.
const res = await fetch(`/api/posts/${params.postId}`, { signal });
return res.json();
},
component: EditPostPage,
});Use a standard HTML <form> element with method="post". There is no special form component needed — the router hooks into the Navigation API which intercepts native form submissions:
function EditPostPage({ data, params }: EditPostProps) {
return (
<form method="post" action={`/posts/${params.postId}/edit`}>
<input name="title" defaultValue={data.title} />
<textarea name="body" defaultValue={data.body} />
<button type="submit">Save</button>
</form>
);
}Note that the form’s action attribute points to the same URL that the route matches. This is essential for progressive enhancement: before hydration, the browser will POST to this URL on the server.
The action feature is designed as an enhancement layer. The baseline behavior of a POST form is a server round-trip, and the router’s action provides a faster, client-side alternative once hydration is complete. This means:
action function on the client, and updates the UI without a full page reload.Both paths should produce the same end result for the user. The client action is a shortcut, not a replacement.
When your server cannot handle POST requests
If you are building a purely client-side application (e.g. a SPA with no server-side form handling), consider using React 19’s <form action={fn}> pattern instead. When a form action is a function rather than a URL, the browser will not attempt a server round-trip on submission. Note that in a client-only app the form will not work until React hydrates, since the function only exists in the JavaScript bundle.
In contrast, FUNSTACK Router’s action intercepts URL-based form submissions. If the client has not hydrated yet, the browser will POST to the URL, which will fail without server handling.
When a route defines both an action and a loader, the loader runs after the action completes. The action’s return value is passed to the loader via the actionResult parameter:
action: async ({ request }) => {
const formData = await request.formData();
// ... process form
return { success: true, message: "Saved!" };
},
loader: async ({ params, actionResult, signal }) => {
// actionResult is { success: true, message: "Saved!" }
// after the action, or undefined on normal navigation
const data = await fetchData(params.id, signal);
return { ...data, actionResult };
},This lets your UI display feedback from the action (e.g. success messages or validation errors) alongside the refreshed data.
action intercepts POST form submissions on the client after hydrationactionResult<form action={fn}> pattern