Skip to content

Server-side validation

The client is not a validator. A FormConfig is public — it ships to the browser so FormRenderer can read it, which means anyone can read it too and POST whatever they want straight to your endpoint, config or no config. parseSubmission is the first server-side trust boundary this engine has. And just as important: it proves shape and rules, never permission.A structurally valid body from user A naming user B's resource parses clean — authorization is always the host's job, done separately, after parsing.

Signature

Pure and synchronous — no I/O, no promise. Reuses the exact schema builder the client's condition-aware resolver uses (buildFieldsSchema), never a forked validation path.

function parseSubmission(
  config: FormConfig,
  rawBody: unknown,
  opts?: {
    otpVerified?: OtpVerifiedChecker;   // required if a visible otp field exists
    messages?: Partial<Messages>;       // i18n overrides, merged over defaultMessages
    maxStringLength?: number;           // default 10_000
  },
):
  | { ok: true; values: FormValues; unvalidated: string[] }
  | { ok: false; code: ParseSubmissionErrorCode; errors: ServerErrorResult; unvalidated: string[] };

code is for server-side logging only — every non-validation_failed failure branch returns the same generic errors.formError copy regardless of cause (tuning dials and otp verification state are not an oracle a client response should expose). validation_failed is the deliberate exception: it returns per-field messages, since a form needs to tell a user which field is wrong.

invalid_body

rawBody isn't a plain JSON object (array, string, number, null, undefined).

otp_checker_missing

a visible otp field exists and opts.otpVerified was omitted. Fails closed — see the OTP section below.

otp_in_group

the config nests an otp field inside a group, at any depth — rejected outright, unconditionally.

input_too_large

a string value, top-level or inside a group row, exceeds maxStringLength (default 10_000).

validation_failed

the body parsed against the config's schema and failed.

Quick start: Next.js Route Handler

The whole server side of a submission is two lines: parse, and if it failed, return the errors as-is.

// app/api/signup/route.ts
import { parseSubmission } from "@/form-builder";
import { signupConfig } from "./config";
import { createAccount } from "@/lib/accounts"; // your own persistence

export async function POST(request: Request) {
  const result = parseSubmission(signupConfig, await request.json());
  if (!result.ok) return Response.json(result.errors, { status: 400 });

  // result.values is the validated payload — create the account, etc.
  await createAccount(result.values);
  return Response.json({ ok: true });
}

result.errors IS a ServerErrorResult — the same shape a host's onSubmit callback may already return from an API failure. Returning it verbatim as the 400 body closes the round trip end to end: the client's existing error-handling path repaints the exact fields that failed, with the exact message the server produced, no server-specific client code required.

"use client";
// FormRenderer's onSubmit may return a ServerErrorResult (or resolve to
// one) — returning it maps fieldErrors -> setError per field and
// formError -> the form's root error slot, via applyServerErrors
// internally. No extra client-side glue needed.
import { FormRenderer } from "@/form-builder";
import { signupConfig } from "./config";

export function SignupForm() {
  return (
    <FormRenderer
      config={signupConfig}
      onSubmit={async (values) => {
        const res = await fetch("/api/signup", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(values),
        });
        if (res.ok) return;
        // res.body IS the ServerErrorResult parseSubmission returned as
        // `result.errors` on the server — return it as-is.
        return res.json();
      }}
    />
  );
}

More framework recipes

Every recipe on this page is the same core: parse, and if it failed, hand back result.errors unchanged. Only the transport around it differs.

Next.js Server Action. onSubmit's contract — (values) => void | ServerErrorResult | Promise<void | ServerErrorResult>— is exactly a Server Action's shape, so it can be passed to onSubmit directly:

// app/signup/actions.ts
"use server";
import { parseSubmission } from "@/form-builder";
import type { FormValues } from "@/form-builder";
import { signupConfig } from "./config";
import { createAccount } from "@/lib/accounts"; // your own persistence

export async function submitSignup(values: FormValues) {
  const result = parseSubmission(signupConfig, values);
  if (!result.ok) return result.errors; // matches onSubmit's return contract
  await createAccount(result.values);
}
"use client";
// A Server Action is just an async function once "use server" marks its
// module — pass it straight to onSubmit, no fetch/route glue at all.
import { FormRenderer } from "@/form-builder";
import { signupConfig } from "./config";
import { submitSignup } from "./actions";

export function SignupForm() {
  return <FormRenderer config={signupConfig} onSubmit={submitSignup} />;
}

A non-Next host (Express shown; Hono, Fastify, etc. are the same shape). parseSubmission has zero Next.js coupling — it's exported from form-builder/headless.ts, the same shadcn-free entry published as @form-builder/engine:

// A non-Next host — same two-line core, wired to Express instead of a Route Handler.
import express from "express";
import { parseSubmission } from "@form-builder/engine"; // published headless entry
import { signupConfig } from "./signupConfig";
import { createAccount } from "./accounts"; // your own persistence

const app = express();
app.use(express.json());

app.post("/api/signup", async (req, res) => {
  const result = parseSubmission(signupConfig, req.body);
  if (!result.ok) return res.status(400).json(result.errors);

  await createAccount(result.values);
  res.json({ ok: true });
});

The otp recipe — the one to get right

parseSubmission is synchronous. Real OTP verification is I/O (a store lookup). The pattern is two-phase: await your own store once, build a lookup from the result, then pass a synchronous closure over that lookup as otpVerified.

The recipe below is the secure version — there is no permissive variant anywhere in this doc:

  • Look the code up against server-side state keyed by a session or challenge id, never by a body field — verifying a code against values.email is worthless when values.email came from the same untrusted request.
  • Compare with crypto.timingSafeEqual, not ===.
  • Consume/expire the code on success — single use.
  • Rate-limit verification attempts.
// app/api/signup/route.ts
import { timingSafeEqual } from "node:crypto";
import { parseSubmission } from "@/form-builder";
import { signupConfig } from "./config";
import { getSession } from "@/lib/session";
import { otpStore } from "@/lib/otpStore"; // your own store — Redis, a DB table, whatever
import { createAccount } from "@/lib/accounts"; // your own persistence

export async function POST(request: Request) {
  const session = await getSession(request); // a session/challenge id — never a body field
  const rawBody = await request.json();

  // Rate-limit attempts BEFORE spending a lookup.
  if (await otpStore.tooManyAttempts(session.id)) {
    return Response.json({ formError: "Too many attempts. Try again later." }, { status: 429 });
  }

  // Phase 1: ONE await against your own store, building a synchronous
  // lookup. parseSubmission is synchronous end to end — it can never await
  // your store mid-call, so this Map has to exist before you call it.
  const challenge = await otpStore.get(session.id); // keyed by session, never rawBody
  const issuedCode = challenge?.fieldName === "otp" ? challenge.code : undefined;

  // Phase 2: a sync closure over what phase 1 fetched — a PURE comparison,
  // no side effects. The checker may run independent of whether the rest
  // of the submission ultimately validates, so it must never consume/expire
  // the code itself (that would burn a correct code on, e.g., a submission
  // that fails only because the password is too short).
  const result = parseSubmission(signupConfig, rawBody, {
    otpVerified: (fieldName, code) => {
      if (fieldName !== "otp" || issuedCode === undefined) return false;
      const submitted = Buffer.from(code);
      const issued = Buffer.from(issuedCode);
      return submitted.length === issued.length && timingSafeEqual(submitted, issued);
    },
  });

  if (!result.ok) return Response.json(result.errors, { status: 400 });

  // Consume/expire the code only now — AFTER the whole submission is known
  // to be valid, not inside the checker. Single-use, correctly enforced.
  await otpStore.consume(session.id);
  await createAccount(result.values);
  return Response.json({ ok: true });
}
Fails closed

Omitting otpVerified when a visible otp field exists returns { ok: false, code: "otp_checker_missing" } — always, unconditionally. There is no skip flag. If you handle OTP verification out-of-band (a separate confirm step before this endpoint runs), the FormConfig you pass to parseSubmission must not include that otpfield at all — the checker requirement is not something you can opt out of on a field that's still in the config.

otp inside a group

An otp field nested inside a group is rejected outright — code: "otp_in_group" — regardless of the submitted body. Group rows are runtime-prefixed ("team.0.code"), a name a session-keyed verified-code registry can never match, so such an otp is unverifiable server-side by construction.

File uploads

file fields are always omitted from server validation, and their names always appear in unvalidated. Three reasons, all structural:

  • z.instanceof(File) throws at schema-build time on a runtime with no global File — this would break server-side schema construction outright, not just fail validation.
  • A JSON request body can never contain a File value in the first place.
  • The host already holds the authoritative storage record (size, content type, hash) once the upload completes — re-deriving that from client-declared metadata would be strictly worse.
Host-owned

The host owns upload validation entirely — size, MIME, and content-sniffing — against its own storage API. Never trust a client-declared MIME type; sniff the actual bytes, or trust what the storage provider reports after the upload completes.

Omitted from the schema only — a visible filefield's raw value still passes through result.valuesunvalidated (same as a custom field type's value), so you don't have to re-read it off the raw request body. The host must still validate it against its own storage record before trusting it.

// The client uploads directly to storage and submits a storage
// reference, never the file bytes, over the JSON body parseSubmission sees.
{ type: "file", name: "resume" } // omitted from schema validation, always

// A typical presigned-upload shape the host validates on its own:
{ "resume": { "key": "uploads/abc123.pdf", "size": 214532, "contentType": "application/pdf" } }

Custom (registered) field types

A field registered via registerField validates, at the config level, as BaseField only — its own props pass through unchecked. parseSubmissionmirrors that pinned engine contract: a custom type's value schema is z.unknown().optional(), so its name always lands in unvalidated next to any file fields, in config.fields order.

Disclosure, not a fail-closed gate

This is a deliberate design choice, not an oversight: parseSubmission discloses what it didn't check via unvalidatedrather than rejecting the whole submission outright. The engine has no way to know what a given custom type's value should look like — only the host that registered the type does. Skipping result.unvalidated is the sharp edge: three lines of zod over result.values[name] closes it.

Reserved-key scrubbing does not recurse into it

parseSubmission's __proto__/constructor/prototypescrub only walks the body's top level and grouprows — a custom field's value passes through raw, including any reserved keys nested inside it. Safe as long as you validate it with your own schema and never deep-merge result.values[name] into another object.

const result = parseSubmission(config, rawBody, { otpVerified });
if (!result.ok) return Response.json(result.errors, { status: 400 });

// result.unvalidated names every field the schema skipped — files, and
// custom registered types. Validate the ones you care about yourself:
const gizmo = z.object({ widgetId: z.string(), qty: z.number().int().min(1) });
const parsedGizmo = gizmo.safeParse(result.values.gizmo);
if (!parsedGizmo.success) {
  return Response.json({ fieldErrors: { gizmo: "Invalid widget selection" } }, { status: 400 });
}

What the body can — and can't — override

disabled is not a lock

A field's disabled: true is a presentational flag — it carries no config-authored value, so there is nothing for parseSubmission to re-assert. Its value is trusted from the body exactly like any other visible field, even though the client-rendered UI never let a user change it.

The rule: a value the server must own belongs in a hidden field, or nowhere in the form at all (read it from the session instead). Never in a disabled one.

hidden fields go the other way — their value is re-injected from the config on every parse, with no opt-out, and always before visibility is computed. That ordering is load-bearing, not incidental: hidden fields are legal visibleWhensources, so if the body's value were trusted even briefly, an attacker could flip which other fields the form treats as required.

This recursion reaches into every group row too, at any nesting depth — a per-row hidden field (a line-item price, a SKU) is exactly as protected as a top-level one.

{
  // hidden: value is re-injected from config BEFORE visibility is computed,
  // on every parse, unconditionally. The body's own "plan" key is discarded.
  type: "hidden",
  name: "plan",
  value: "pro",
},
{
  // disabled: a purely presentational flag. It carries no config value to
  // re-inject, so parseSubmission has nothing to enforce here — the body's
  // "discountPercent" is trusted as submitted, same as any other field.
  type: "number",
  name: "discountPercent",
  disabled: true,
}

Conditions on the server

parseSubmission computes visibility the same way the client does — visibleFieldsFor, which honors both a field's own visibleWhenand its owning step's — not stripInvisibleValues (see below).

Known v1 limitation, inherited identically

Conditions on fields nested inside a group are not evaluated by validation, client or server. The server deliberately does notget stricter here — making it so would reject submissions the UI itself accepted, which is worse than the limitation it would "fix."

{
  type: "group",
  name: "team",
  fields: [
    { type: "checkbox", name: "hasRole" },
    // Still validated as required server-side even when hasRole is false —
    // identical to the client's known v1 limitation, not a server-only bug.
    { type: "text", name: "role", required: true, visibleWhen: { field: "hasRole", equals: true } },
  ],
}
Conditions run on attacker-controlled input

visibleWhen is evaluated against the submitted body itself — a client can hide a required field by submitting whatever value hides it. This is inherent to conditional forms, not a bug in parseSubmission: the mitigation is that the field controlling visibility is itself validated, so an attacker can only reach visibility states a legitimate user could also reach. If a field must be validated unconditionally, it must not be conditional.

What's out of scope, on purpose

maxStringLength (default 10,000) bounds string content — it exists to bound ReDoS amplification from a config rules.pattern and to cap oversized signaturedata-URLs. A second, fixed, non-configurable depth cap (32 levels) also exists, purely so a maliciously deep, nested body can't turn this size check's own recursion into a stack overflow. Both are self-protection for parseSubmission's own checks, nothing broader.

The host's job

Overall request body size, rate limiting, and how many rows a groupis allowed to submit are all the host's concern, enforced at the edge/framework layer — a body-size cap on the Route Handler itself, a WAF rule, an API gateway limit. parseSubmission does not attempt any of these; adding a second or third limit inside the library would just be a worse, less-configurable copy of what the platform layer already does well.

A malformed FormConfig throws, unconditionally — validateFormConfig always runs (configs may be CMS-sourced), and parseSubmission does not swallow its exception.

A 500, deliberately — not a 400

A broken config is an authoring/deployment error, not user input gone wrong. Catching it into a 400 would make a real outage look like ordinary form validation noise in your logs and dashboards — let it surface as a 500 (or don't catch it) so it pages someone.

// A malformed config THROWS — parseSubmission does not catch it for you.
try {
  const result = parseSubmission(config, rawBody, { otpVerified });
  // ...
} catch {
  // A broken config is an authoring error, not bad user input. Let this
  // surface as a 500 (or don't catch it at all) — folding it into a 400
  // would hide a real outage from monitoring behind ordinary form noise.
}
stripInvisibleValues is not a server sanitizer

stripInvisibleValues is a client-side helper for headless getValues() consumers — it passes through undeclared keys, has no __proto__scrub, and is blind to step visibility (fields- only). It is tempting to reach for by name when writing a server sanitizer; don't. Use parseSubmission for anything touching a request body.

See form-builder/core/parseSubmission.ts for the full step-by-step contract (the order of its 12 steps is itself the security property), and Multi-step wizards for the client-side otp/dependsOnwiring this recipe's server side pairs with, worked through in the multi-step signup example. ADR-0004 records the pinned rulings behind this design (sync-not-async, fail-closed otp, its two size limits, disclosure via unvalidated instead of a fail-closed custom-type gate).