Skip to content

Validating on the server ​

The schema a definition builds is a plain Valibot schema, so it validates on a server just like in the browser. Never trust what a request sends: run it through the same schema the form used. Below you will learn how, and what changes when the value travels as JSON.

Parse the request ​

Build the schema from the same definition the form rendered, with a builder that has the same options, and parse the request's body with v.safeParse:

ts
// The client's builder options: extensions, types, rules and messages.
const builder = createSchemaBuilder({ extensions: [affects()] })
const schema = builder.build(definition)

function handleSignup(body: unknown) {
  const result = v.safeParse(schema, body)
  if (!result.success) {
    // { email: ['Please enter your email'] }, by the dot path of each field
    return {
      status: 422,
      errors: v.flatten<typeof schema>(result.issues).nested,
    }
  }
  return { status: 201, output: result.output }
}
  • Use the same builder options as the client: the same extensions, field types, rules and messages. Without the affects extension, for example, a definition with conditions fails to build.
  • Use the same definition. When people edit your definitions, store each version, and validate a request against the one the form showed, for example by an id the form sends along.
  • Keys the definition doesn't know are dropped from the output, as v.object does.
  • Form rules and conditions run on the server too. A hidden field isn't validated. A field that hideAndClear hides outputs nothing, and one hidden otherwise outputs its value when it is valid.
  • Build the schema once for each definition, and reuse it for every request.

Missing keys ​

Formisch always sends every key of the form, but a request from somewhere else may leave one out. A required field whose key is missing reports its required message, the same as one that is undefined:

ts
handleSignup({}) // 422, email: 'Please enter your email'

Before 1.0, it reported Valibot's own Invalid key: Expected "email" but received undefined. See migrating to 1.0.

Dates ​

A date or datetime with "output": "date" outputs a Date. JSON has no dates, so JSON.stringify writes it as a full timestamp, which the field rejects on the server:

ts
const output = v.parse(schema, {
  email: 'jane@example.com',
  birthDate: '2000-01-15',
})
// output.birthDate is a Date. JSON.stringify writes it as a timestamp,
// like "2000-01-14T23:00:00.000Z", which a `date` rejects.
const fromOutput = handleSignup(JSON.parse(JSON.stringify(output))) // 422
const fromInput = handleSignup({
  email: 'jane@example.com',
  birthDate: '2000-01-15',
}) // 201

So send the form's input, not its output. Formisch's getInput(form) gives it, and the server's parse makes the same output from it. Or leave out "output": "date" in forms that go to a JSON API, and turn the ISO string into a date on the server.

The same goes for any output that JSON changes: validate the value the schema takes, never the one it gave.

Files ​

A file holds a File, which JSON can't carry. Send the request as multipart/form-data instead: the input as JSON in one part, and each file in a part of its own. On the server, put the files back where the definition has them, and parse:

ts
// In the browser: the form's input, from Formisch's getInput(form), as
// JSON in one part, and each file in a part of its own.
const formInput = {
  email: 'jane@example.com',
  cv: new File(['%PDF'], 'cv.pdf', { type: 'application/pdf' }),
}
const { cv, ...rest } = formInput
const body = new FormData()
body.append('input', JSON.stringify(rest))
body.append('cv', cv)
const request = new Request('https://example.com/signup', {
  method: 'POST',
  body,
})

// On the server: put the files back in place, then parse.
const data = await request.formData()
const input = {
  ...JSON.parse(String(data.get('input'))),
  cv: data.get('cv') ?? undefined,
}
const withFile = handleSignup(input) // 201
  • Node 20 and later have File, FormData and Request as globals, and most servers give a request's formData(). The schema reads the File global while it parses.
  • For a field with "multiple": true, append each file under the same name, and read them with data.getAll(name).
  • A File only knows the MIME type the browser gave it. accept and the file rules check that and the size, not the content, so check the content of a file on your server before you trust it.

Next steps ​

See supported versions for the Node versions the packages run on, and checking definitions to check a definition before you store it.