Client forms
Use client.form for browser forms that mutate application state. It connects
an imported route method to the form, serializes its fields, preserves Primate's
validation response format, and exposes reactive submission and error state.
Choose a submission pattern
| Situation | Preferred pattern |
|---|---|
| One browser mutation | client.form(route.post) |
| Dynamic route | client.form(route.post, { path }) |
| Structured JSON body | client.form(route.post, { serialize }) |
| Field validation | throw p.error(field, message) and read form.field(field).error |
| API or programmatic endpoint | Explicit fetch and HTTP responses |
| Multiple actions | Separate forms or an explicit hidden intent field |
Use the frontend package that renders the form, for example
@primate/svelte/client, @primate/react/client, or
@primate/angular/client.
Bind a route action
Import the route and pass its mutation method to client.form:
<script lang="ts">
import route from "@/routes/account/profile";
import client from "@primate/svelte/client";
const form = client.form(route.post);
</script>
<form id={$form.id} onsubmit={$form.submit}>
<input name="displayName" />
{#if $form.field("displayName").error}
<p>{$form.field("displayName").error}</p>
{/if}
<button disabled={$form.submitting}>Save</button>
</form>The route method supplies the request content type, body type, and result type.
The adapter supplies framework-specific reactive values for submitting,
submitted, result, form errors, and field errors.
Dynamic route paths
A route imported from a dynamic filename still needs concrete path values. For
routes/project/[project]/settings/roles.ts, pass project separately from the
form body:
<script lang="ts">
import route from "@/routes/project/[project]/settings/roles";
import client from "@primate/svelte/client";
let { project } = $props<{ project: { name: string } }>();
const form = client.form(route.post, {
path: { project: project.name },
});
</script>TypeScript requires the options argument for a dynamic route and checks the
path names and values. Static route forms can continue to omit it.
Path values identify the route; inputs inside <form> supply its body. Do not
add a dynamic path value as a hidden field in place of the path option.
Structured JSON bodies
By default, a JSON route receives a flat object created from the form's
successful controls. Use serialize when the route expects nested objects,
arrays, booleans, or another structure that HTML field names cannot represent
unambiguously.
<script lang="ts">
import route from "@/routes/account/preferences";
import client from "@primate/svelte/client";
const form = client.form(route.post, {
serialize(data) {
return {
notifications: {
enabled: data.has("notifications[enabled]"),
channels: data.getAll("notifications[channels]").map(String),
preferences: {
desktop: data.has("notifications[preferences][desktop]"),
bell: data.has("notifications[preferences][bell]"),
},
},
};
},
});
</script>The serializer receives the submitted FormData, including repeated controls
and the initiating submit button. Its return type is checked against the
imported route's declared body schema. It may also read component state instead
of the FormData when that state is the authoritative value.
The returned value enters the normal form lifecycle: submitting state, route interpolation, redirects, results, and Pema issue handling remain active. This option does not change URL-encoded or multipart forms.
Pema JSON Pointer issue paths map to bracketed form field names. For example,
/notifications/preferences/desktop maps to
notifications[preferences][desktop]. Use matching name attributes when a
nested input needs field-level error display, and read it with
form.field("notifications[preferences][desktop]").error (or the adapter's
reactive equivalent).
Bracketed names are checked against the route body type. Object segments must
name declared properties and array segments must be numeric indices. A
Record<string, T> deliberately permits any key at that record segment while
continuing to check paths inside T. The field's value follows the same path
through initial, so field("notifications[preferences][desktop]").value
returns the nested initial boolean rather than the top-level object.
Field validation
Throw a Pema field error when a mutation fails validation that depends on application state:
import p from "pema";
if (await role_exists(role)) {
throw p.error("role", "This role already exists");
}client.form maps that issue to the matching field. Read it through the
adapter's field view, for example $form.field("role").error in Svelte or
form.field("role").error in React and Solid. Schema validation errors are
mapped the same way. Use the form-level errors value for failures without a
specific field.
Do not replace a field error with an unrelated raw 400 or 409 response.
That discards the structured issue the form needs to render beside the input.
Multiple actions
Prefer one form for each mutation. If several controls must share a form, put the selected operation in an explicit hidden field and validate it on the server:
<input type="hidden" name="intent" value="draft" />Update that value deliberately before submission. Do not make correctness depend only on which submit button a browser reports as the submitter; keyboard submission and programmatic submission may not select the button you expect.
When to use fetch
Use explicit fetch for endpoints designed as programmatic APIs, background
requests without a form, streaming, or protocols that need custom status and
header handling. Handle the response as an HTTP response in those cases.
Do not start with fetch for an ordinary browser mutation. It duplicates body
serialization, submitting state, route interpolation, and Pema issue mapping
that client.form already provides.
Troubleshooting
A dynamic path is undefined
A message such as /project: expected string, got undefined means the route
method did not receive its path parameters. Pass them in the second argument:
client.form(route.post, { path: { project: project.name } });
Submitting navigates to a raw 400 or 409 response
Check that the form's submit event is bound to form.submit and that its id
uses form.id. Then return or throw structured Pema validation issues for
form-facing errors. A native form submission navigates to the response because
the client handler did not intercept it.
The endpoint is not a browser form
Use fetch when the caller needs to inspect raw statuses, headers, or an API
payload. Keep that endpoint's HTTP contract separate from form-oriented field
validation.