Forms

Validate on the server with the same schema

Client validation is UX. Export one schema, parse it again in the route action, return submission.reply(), and feed that back through useForm({ lastResult }) — a form whose only validation is onValidate is an unvalidated form.

Tier 3errorlintvalidate-on-the-server-with-the-same-schema

Validation is yours. Wiring is the library's.

What this catches

An agent asked for a validated form writes `onValidate` and stops, because the form is now visibly validating. The action it also wrote parses nothing, so the validation is decoration — anything not sent from that form goes straight through.

Why

onValidate runs in a browser the user controls. Nothing that reaches your database went through your form — it went through the network, and a request made with curl skips every check the client performed. Client-side validation exists so people find out about a mistake without waiting for a round trip; it is not a gate, and treating it as one is how a required field turns out not to be required.

One schema module imported by both sides is what keeps the two ends in agreement. The alternative — a schema in the component and a handful of if-statements in the action — drifts on the first change, and the drift presents as the worst kind of bug: a form that passes in the browser and fails on the server, or worse, passes on both while enforcing different things.

lastResult is the return path, and it is why the server does not need a second error-display code path. submission.reply() carries the server's errors back into the same field metadata the inputs already read, so a rejection from the action renders in exactly the place a client-side error would, in the same styling, with the same aria wiring.

Wrong / right

A form that validates only in the browser

Don't

const [form, fields] = useForm({
  onValidate: ({ formData }) => parseWithValibot(formData, { schema }),
})

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData()
  // The schema never runs here. A POST from anywhere but this form is unchecked.
  await createUser({ email: String(formData.get("email")) })
  return redirect("/welcome")
}

Do

// app/schemas/signup.ts — one module, imported by both sides
export const signupSchema = v.object({
  email: v.pipe(v.string(), v.email("Enter a valid email address")),
})

// app/routes/signup.tsx
export async function action({ request }: Route.ActionArgs) {
  const submission = parseWithValibot(await request.formData(), { schema: signupSchema })
  if (submission.status !== "success") return submission.reply()

  await createUser(submission.value)
  return redirect("/welcome")
}

submission.value is typed and parsed, so the String(formData.get(...)) casts disappear along with the vulnerability.

Getting the server's answer back into the fields

Don't

const [form, fields] = useForm({
  onValidate: ({ formData }) => parseWithValibot(formData, { schema: signupSchema }),
})
// The action rejects the email as already taken. Nothing on screen changes.

Do

export default function Signup({ actionData }: Route.ComponentProps) {
  const [form, fields] = useForm({
    lastResult: actionData,
    onValidate: ({ formData }) => parseWithValibot(formData, { schema: signupSchema }),
  })
  // "That email is already registered" now renders under the email field,
  // through the same metadata the client-side errors use.
}

This is the half people skip, and it is the half that makes server validation usable rather than just safe. Without lastResult a server rejection is a silent no-op to the person filling in the form.

How to check this

Add one of these to your project and the rule holds without anyone having to remember it — including the agent writing half the JSX. The exceptions below are already applied, so a documented carve-out will not be reported.

What it will and will not catch: The check looks for the missing option, which is a proxy: it cannot confirm your action actually re-parses the schema, and it will fire on a form that is genuinely client-only (scope that away with the exceptions rather than switching it off). It only reads options written inline — useForm(options) with the object in a variable is skipped on purpose, because the alternative is reporting every such call whether or not it passes lastResult. Reading it the other way round is the useful part: a form that passes has somewhere to put the server's answer.

Biome — GritQL plugin

biome

Biome has no built-in rule for this one, so it ships as a GritQL plugin. Save it as ui-lib-rules/validate-on-the-server-with-the-same-schema.grit and add that path to `plugins` in your biome.jsonc. Its documented exceptions are compiled in as `$filename` guards, because Biome's overrides do not scope plugins.

// Validate on the server with the same schema
// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules/validate-on-the-server-with-the-same-schema.json — do not edit by hand.
//
// https://ui-lib.quebi.de/rules/validate-on-the-server-with-the-same-schema

language js;

`useForm($options)` as $call where {
  $options <: JsObjectExpression(),
  $options <: not contains `lastResult`,
  // documented exception: **/*.stories.{tsx,jsx}
  not $filename <: r"(?:.*/)?[^/]*\.stories\.(?:tsx|jsx)$",
  // documented exception: **/*.examples.{tsx,jsx}
  not $filename <: r"(?:.*/)?[^/]*\.examples\.(?:tsx|jsx)$",
  register_diagnostic(
    span = $call,
    message = "useForm without lastResult: this form cannot display anything the server says, which in practice means the server is not validating. Parse the same schema in your route action, return submission.reply(), and pass it here as lastResult. See https://ui-lib.quebi.de/rules/validate-on-the-server-with-the-same-schema",
    severity = "error"
  )
}

ripgrep — no setup at all

ripgrep

Finds candidates for review in any repo, linter or not. Coarser than the Biome check: it reads lines, not syntax, so expect false positives and treat a clean run as weaker evidence than a clean lint run.

# validate-on-the-server-with-the-same-schema — candidates for review
rg -n -g '*.{tsx,jsx}' \
  -g '!**/*.stories.{tsx,jsx}' \
  -g '!**/*.examples.{tsx,jsx}' \
  "useForm\\("

Claiming an exception that is not a path

biome

One exception on this rule is a judgement call, so it cannot be a path. Biome's suppression syntax has a slot for the reason — fill it, because that note is what makes the carve-out reviewable instead of invisible.

{/* biome-ignore plugin: A form with no server side at all — A filter panel, a calculator, a wizard step held in component state — nothing is submitted, so there is nothing to re-validate. */}

Enforcing more than this one rule? Take the whole config instead of collecting snippets.

Exceptions

Carve-outs are part of the rule, not a way around it. Each one is already an ignore glob in the checks above, so the cases listed here need no disable comment — and a case that is not listed is one to argue for, not to silence.

Gallery, story and example files
A rendered example has no route action to post to, so it validates client-side by definition. Worth knowing when reading the ui-lib gallery: those forms are demonstrations of a binding, not a template for a real one — every one of them would fail this rule.
A form with no server side at all
A filter panel, a calculator, a wizard step held in component state — nothing is submitted, so there is nothing to re-validate. If it later grows an action, this rule arrives with it.

Scope and enforcement

Applies to
  • app/**/*.{tsx,jsx}
  • src/**/*.{tsx,jsx}
Enforced by
lint