rule 10 — tier 3

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.

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

forms

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.

The typed call is the same call

don't

// useForm<Schema>({ … }) is the spelling Conform's docs lead with, because it is
// what makes fields.email typed. It is still a form with nowhere to put a server error.
const [form, fields] = useForm<SignupSchema>({
  onValidate: ({ formData }) => parseWithValibot(formData, { schema: signupSchema }),
})

do

const [form, fields] = useForm<SignupSchema>({
  lastResult: navigation.state === "idle" ? actionData : null,
  onValidate: ({ formData }) => parseWithValibot(formData, { schema: signupSchema }),
})

The type argument says what the fields are, not where they are checked. The check reads both spellings — until task #163 it read only the untyped one, which meant it was quiet on most real forms.

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. A type argument is not a blind spot: useForm({ … }) and useForm<Schema>({ … }) are read the same way. 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 load it from an `overrides` entry — the one in the file's own header, which lists the paths the rule applies to (app/**/*.{tsx,jsx}, src/**/*.{tsx,jsx}) and the paths its documented exceptions carve back out. Not from the top-level `plugins`: a plugin listed there runs against every file you lint, and an override can add one but cannot take one away. If your project keeps its components somewhere else, edit the `includes` to match.

// 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
//
// This file says what the rule is, not where it applies. A plugin listed in
// the top-level `plugins` runs against every file Biome lints, so scope it
// with an `overrides` entry — Biome resolves `includes` against the project
// root, which is the one thing a $filename regex in here could not do:
//
//   {
//     "overrides": [
//       {
//         "includes": [
//           "app/**/*.{tsx,jsx}",
//           "src/**/*.{tsx,jsx}",
//           "!src/components/**",
//           "!components/ui/**",
//           "!**/*.stories.{tsx,jsx}",
//           "!**/*.examples.{tsx,jsx}"
//         ],
//         "plugins": [
//           "./ui-lib-rules/validate-on-the-server-with-the-same-schema.grit"
//         ]
//       }
//     ]
//   }

language js;

JsCallExpression(
  callee = `useForm`,
  arguments = JsCallArguments(args = [$options])
) as $call where {
  $options <: JsObjectExpression(),
  $options <: not contains `lastResult`,
  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 '*.{jsx,tsx}' \
  -g '!src/components/**' \
  -g '!components/ui/**' \
  -g '!**/*.stories.{tsx,jsx}' \
  -g '!**/*.examples.{tsx,jsx}' \
  "useForm\\s*[<(]"

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. The rule is named by its plugin's file name, validate-on-the-server-with-the-same-schema.grit — a suppression that names this rule quiets this rule and no other plugin on the same line, and if the file is renamed Biome reports the comment as having no effect rather than guessing. `lint/plugin` on its own is valid too and quiets every plugin rule on the node at once; name the rule instead.

// biome-ignore lint/plugin/validate-on-the-server-with-the-same-schema: 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.

// Between JSX children, where a comment has to be an expression, the same
// line is written {/* biome-ignore … */}.

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.

Your copy of the ui-lib component source (components/ui/**)
A ui-lib form is a control surface, not a submission. table-controls.tsx builds six of them — the search box, the column picker, a column's filter panel, the page-size picker, the row editor and the cell editor — and every one ends in onSubmit: event.preventDefault(). They use Conform for field metadata and validation, not for a round trip; the query they produce goes back to the caller through onQueryChange, and it is the caller's route that has an action to validate against. There is no lastResult for them to carry and nowhere for one to come from — which is the "no server side at all" exception below, arriving at an address. It is on the record rather than in this repo's local scopes because the file is vendored verbatim: a consumer who installs table-controls owns the same six calls under their own components/ui/, and a rule that reports them there is reporting the library's shape, not their code. (Nobody noticed until task #163: all six are written useForm<T>({ … }) and the check matched only the untyped call.)
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