Primate LogoPrimate

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.