Skip to content

Server-side validation ​

validateSubmission() from @formhaus/core/server checks a submitted payload against the same JSON definition the browser renders.

bash
npm install @formhaus/core

Express ​

ts
import express from 'express';
import { parseDefinition } from '@formhaus/core';
import { validateSubmission } from '@formhaus/core/server';
import json from './definition.json' with { type: 'json' };

const definition = parseDefinition(json);
const app = express();
app.use(express.json({ limit: '100kb' }));

app.post('/api/submit', (req, res) => {
  const { values, errors } = validateSubmission(definition, req.body?.values, {
    skippedSteps: req.body?.skippedSteps,
  });
  if (Object.keys(errors).length > 0) return res.status(422).json({ errors });
  res.json({ values });
});

app.listen(3000);

Save values, not req.body.values. validateSubmission() throws a FormDefinitionError when the definition itself has an error, so check stored definitions with checkDefinition() before you save them. Next.js and Nuxt versions are in Next.js and Nuxt.

Client ​

Post the values and the skipped step ids. Pass the 422 errors back through the errors prop.

tsx
const [errors, setErrors] = useState<Record<string, string>>();

async function submit(values: Record<string, unknown>, skippedSteps?: string[]) {
  const response = await fetch('/api/submit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ values, skippedSteps }),
  });
  if (response.status === 422) setErrors((await response.json()).errors);
}

<FormRenderer definition={definition} errors={errors} onSubmit={submit} />
vue
<script setup lang="ts">
const errors = ref<Record<string, string>>();

async function submit(values: Record<string, unknown>, skippedSteps?: string[]) {
  const response = await fetch('/api/submit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ values, skippedSteps }),
  });
  if (response.status === 422) errors.value = (await response.json()).errors;
}
</script>

<template>
  <FormRenderer :definition="definition" :errors="errors" @submit="submit" />
</template>

The second argument of validateSubmission() is the values object, not the whole request body. Passing the body adds an error under the values key that says so.

What it checks ​

Both run the validation rules on the active path and skip hidden fields, hidden steps and inactive branches. The returned values hold only fields on the active path; unknown keys are dropped. validateSubmission() also checks what a browser cannot be trusted to send:

CheckvalidateSubmission()FormEngine.validate()
Steps in skippedSteps dropped, only when the step has skipYesNo
number takes a number or a decimal string, returned as a numberYesNo. "5" skips min and max
checkbox and switch take booleansYesNo
select and radio values come from options, multiselect takes an array of themYesNo
email, date (YYYY-MM-DD) and datetime (ISO 8601) formatsYesNo
Strings over 10,000 characters or maxLength rejected before other rulesYesNo
Named validator functionsIgnoredYes

FormEngine.validate() is the browser check. Do not use it on untrusted input.

The browser runs the same type checks as validateSubmission() for the built-in types: email format, date and datetime shape, numbers, booleans, static options, and the 10,000 character cap on text.

Custom validators ​

validateSubmission() ignores validation.validator unless you pass the function in validators:

ts
import type { ValidatorFn } from '@formhaus/core';

const validators: Record<string, ValidatorFn> = {
  companyAvailable: (value) => (String(value).toLowerCase() === 'acme' ? 'This company already has a workspace' : null),
};

const { values, errors } = validateSubmission(definition, req.body?.values, {
  skippedSteps: req.body?.skippedSteps,
  validators,
});

Validators run only for fields on the active path, never on an empty value.

Limits ​

  • pattern rules run on untrusted input. Bound the execution time of the call, or keep patterns simple.
  • validateSubmission() does not limit the request body. Set a limit in your framework or proxy.

Check a definition ​

checkDefinition() returns a list of issues, for example before you store a definition an agent wrote. Each issue has a code, a severity of error or warning, a JSON pointer path, and a message:

ts
import { checkDefinition } from '@formhaus/core/server';

const errors = checkDefinition(definition).filter((issue) => issue.severity === 'error');

Embedded runtimes ​

@formhaus/core/server.iife.js exposes validateSubmission and checkDefinition on a Formhaus global, for JS runtimes without module loading.

Formhaus is MIT licensed. Legal