Form validation

Server validates and returns a per-field error map plus the submitted values. The page reads both via actionData and renders inputs with error styling while keeping the user's typing intact. A successful submit 303s to a confirmation URL — clear distinction between failure and success.

Tip
Returning a plain object from the action (rather than a Response) triggers the action → loader → render path: the page receives errors via actionData without leaving the current URL. The 303 redirect on success makes the confirmation URL refreshable and prevents form re-submission.

Try it

Try submitting empty, a bad email, or a username that's too short.

src/app/examples/validation/page.tsxTSX
// Returning a plain object on failure re-renders the page with
// actionData — field errors + the user's typed values, no redirect.
// A successful submit 303s to a shareable confirmation URL.

export async function action({ request }) {
  const form = await request.formData();
  const { values, errors } = validate(form);
  if (Object.keys(errors).length > 0) {
    return { ok: false, values, errors }; // re-renders page via actionData
  }
  return new Response(null, {
    status: 303,
    headers: { Location: "/examples/validation?ok=" + values.username },
  });
}

// Page reads errors and prefilled values from actionData:
function field({ name, errors, values, ...rest }) {
  return (
    <label>
      <input
        name={name}
        value={values[name] ?? ""}
        aria-invalid={errors[name] ? "true" : undefined}
        {...rest}
      />
      {errors[name] ? <span class="err">{errors[name]}</span> : null}
    </label>
  );
}