Routing & Data
Form Actions
Form Actions — Routing & Data documentation for BCP Framework.
Last updated: 2026-08-29T01:11:41
Form Actions and Server Mutations
BCP form actions provide a route-scoped server mutation primitive for application forms without requiring an API route for every create, update or delete operation.
This feature is intentionally separate from future BCP Server Actions. In this release, client pages do not import a server function directly. A page submits a named action such as "saveUser", and BCP resolves that name from the sibling actions.ts / actions.tsx file on the server.
Route convention
app/users/[id]/
├─ guard.ts # optional
├─ loader.ts # optional read path
├─ actions.ts # server-only mutation path
└─ page.tsxOnly one of actions.ts or actions.tsx may exist next to a page.
Defining an action
// app/users/[id]/actions.ts
import "bcp/server-only";
import {
cookies,
redirect,
type PageActionContext,
} from "bcp/server";
import {
revalidatePath,
revalidateTag,
} from "bcp/cache";
export async function saveUser(
formData: FormData,
context: PageActionContext
) {
const name =
String(
formData.get("name") ?? ""
).trim();
// Perform the database mutation here.
const cookieStore =
await cookies();
cookieStore.set(
"last-user",
context.params.id,
{
httpOnly: true,
sameSite: "lax",
path: "/",
}
);
revalidatePath("/users");
revalidateTag("users");
return {
ok: true,
id: context.params.id,
name,
};
}
export async function removeUser(
_formData: FormData,
context: PageActionContext
) {
// Delete from the database.
revalidatePath("/users");
return redirect(
"/users",
303
);
}An action receives:
formData— the submitted browserFormData.context.params— matched dynamic route parameters.context.searchParams— a freshURLSearchParamsfor the target page URL.context.guardData— merged data from route guards that ran before the action.context.method— the semantic mutation method:POST,PUT,PATCH, orDELETE.
The normal bcp/server request context is active while the action runs, so cookies(), headers(), requestUrl(), requestMethod(), requestId(), getSession() and other server helpers work directly. requestMethod() reflects the semantic action method rather than the internal POST transport.
Rendering a form
"use client";
import {
Form,
useActionData,
useActionError,
useFormStatus,
} from "bcp";
interface SaveResult {
ok: boolean;
id: string;
name: string;
}
export default function UserEditor() {
return (
<Form
action="saveUser"
method="patch"
>
<input
name="name"
required
/>
<SubmitButton />
<Result />
</Form>
);
}
function SubmitButton() {
const status =
useFormStatus();
return (
<button
type="submit"
disabled={status.pending}
>
{status.pending
? "Saving..."
: "Save"}
</button>
);
}
function Result() {
const data =
useActionData<SaveResult>();
const error =
useActionError();
if (error) {
return (
<p role="alert">
{error.message}
</p>
);
}
if (!data) {
return null;
}
return (
<p>
Saved {data.name}
</p>
);
}Supported mutation methods
<Form> supports:
<Form action="createUser" method="post" />
<Form action="replaceUser" method="put" />
<Form action="updateUser" method="patch" />
<Form action="deleteUser" method="delete" />Browsers only support GET and POST as native HTML form methods. BCP therefore uses POST as the wire transport for progressive enhancement and preserves the intended method separately. The action context and requestMethod() still report PUT, PATCH, or DELETE as requested.
GET is deliberately not an action method. Reads belong in page loaders or API GET handlers.
Action return values
A mutation may return either:
- JSON-safe action data.
undefined, which BCP normalizes tonull.- A Web
Response, commonlyredirect().
Serializable action data follows the same safety model as loader data: primitives, arrays and plain objects are allowed. Functions, symbols, BigInt, non-finite numbers, Date/Map/Set/class instances and circular references are rejected before transport.
For validation-style UI in this release, return a serializable object such as:
return {
ok: false,
fieldErrors: {
email: "Email is required",
},
};Typed validation helpers and CSRF-specific APIs are planned as a separate security/validation milestone.
Pending, data, and transport errors
Inside a <Form> subtree:
useFormStatus()exposespending,action,method, and the current transport/server error.useActionData<T>()exposes the most recent serializable action result.useActionError()exposes the most recent thrown transport/runtime error.
Submitting the form again clears the previous action data before the new request begins.
Redirects
Actions can return a normal BCP redirect:
return redirect(
"/users",
303
);With JavaScript enabled, BCP converts the redirect into an action transport payload and continues with client navigation. Without JavaScript, the browser receives the normal HTTP redirect.
Cookies set before the redirect are preserved in both paths.
Revalidation and refreshing loaders
revalidatePath() and revalidateTag() can be called directly inside an action to invalidate BCP data cache entries:
revalidatePath("/users");
revalidateTag("users");If the current page should immediately rerun its loader after a successful data-returning action, enable the form's refresh option:
<Form
action="saveUser"
method="patch"
refresh
>
...
</Form>refresh uses the existing BCP router refresh path after the action result is received. Redirecting actions do not need refresh.
Progressive enhancement
The rendered HTML form posts to BCP's internal action endpoint using a normal browser POST. The target page is recovered from the same-origin referrer when JavaScript is unavailable.
When an action returns serializable data and no explicit Response, the no-JavaScript path uses POST/Redirect/GET with HTTP 303 back to the original target page. This prevents duplicate browser resubmission on refresh.
With JavaScript enabled, <Form> intercepts the submit and sends the same FormData through the action transport without reloading the document.
Middleware, guards, and request order
For a mutation, the effective server order is:
Security gateway
↓
Project middleware
↓
Resolve target page
↓
Route guards (root → child)
↓
Named action
↓
Response cookies / redirect / dataA guard redirect prevents the action from running. Guard data is passed into context.guardData, so an authorization lookup does not need to be repeated in the action.
Why action names are strings
This release intentionally uses:
<Form action="saveUser">and not:
<Form action={saveUser}>Passing a server function through a client bundle requires a compiler-generated server reference, serialization protocol and action identifier system. That belongs to the later BCP Server Actions milestone. Keeping named route actions separate now prevents server/database modules from being pulled into hydrated client graphs.
When to use an API route instead
Use a form action when the mutation belongs to a BCP page UI.
Keep an API route when the caller is an external client such as:
- a mobile application,
- another backend service,
- a webhook provider,
- Postman/API consumers,
- a public or versioned HTTP API.
A project can use both patterns: loaders for page reads, guards for authorization, form actions for page-owned mutations, and API routes for external HTTP contracts.