When form rules are defined outside the frontend — in an OpenAPI spec, a Python or Java backend, a CMS that lets editors build forms — rewriting them as Zod schemas means maintaining two copies that drift. JSON Schema is the language-neutral format those systems already emit, and Ajv validates it in the browser quickly.

The friction points are specific: Ajv compiles schemas with new Function, which a strict Content Security Policy blocks; its default errors are developer-oriented (must have required property 'email') and attached to the parent object for required properties; and format: "email" does nothing unless you add ajv-formats. This page, part of choosing a schema validation library, addresses each.


Context and prerequisites

Choose JSON Schema and Ajv when:

  • The schema is authored elsewhere — generated from backend models, published in an OpenAPI document, or defined by a form builder.
  • Several languages must agree — a Go service, a Python worker and a web form validating the same payload.
  • Schemas arrive at runtime — a CMS-driven form whose fields are not known at build time.

Key Ajv options for forms:

  • allErrors: true — report every failure, not just the first (Ajv stops at the first by default).
  • ajv-formats — adds email, date, uri and others; without it, format keywords are ignored or rejected depending on strict settings.
  • coerceTypes — converts form strings to numbers and booleans; convenient but lenient, like any coercion.
  • Standalone code generation — compile schemas to JavaScript modules at build time, so the browser never calls new Function.
Ajv error objects and what a form needs from them instancePath is a JSON Pointer to the invalid value, such as slash address slash postcode, and is mapped to a form field; for required properties it points to the parent object, so the missing property name must be read from params. keyword names the failed rule, such as required, minLength or format, and selects the user-facing message template. params carries rule details such as missingProperty or limit, used in messages. message is Ajv's English default and should not be shown to users. Field Example Form uses it to instancePath /address/postcode find the field (JSON Pointer) keyword minLength, format, required choose the message params { missingProperty: "email" } fill in details; fix required paths message must NOT have fewer than 5 characters not for users

The core pattern: compile once, map errors to fields with readable messages

import Ajv, { type ErrorObject } from "ajv";
import addFormats from "ajv-formats";

// A schema that might come from an API or a CMS.
const contactSchema = {
  type: "object",
  required: ["name", "email"],
  properties: {
    name: { type: "string", minLength: 1, title: "your name" },
    email: { type: "string", format: "email", title: "your email address" },
    age: { type: "integer", minimum: 16, title: "your age" },
    address: {
      type: "object",
      required: ["postcode"],
      properties: { postcode: { type: "string", pattern: "^[A-Za-z0-9 ]{3,10}$", title: "your postcode" } },
    },
  },
  additionalProperties: false,
} as const;

const ajv = new Ajv({ allErrors: true, strict: true, coerceTypes: false });
addFormats(ajv, ["email", "date", "uri"]);
const validate = ajv.compile(contactSchema);        // compile ONCE, reuse

// "/address/postcode" → "address.postcode"; required errors point at the parent.
function fieldPath(e: ErrorObject): string {
  const base = e.instancePath.split("/").slice(1).map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
  if (e.keyword === "required") base.push(String((e.params as { missingProperty: string }).missingProperty));
  return base.join(".");
}

// Find the subschema's title to name the field in messages.
function labelFor(path: string): string {
  let node: any = contactSchema;
  for (const key of path.split(".")) node = node?.properties?.[key];
  return node?.title ?? "this field";
}

const templates: Record<string, (label: string, p: any) => string> = {
  required: (l) => `Enter ${l}.`,
  minLength: (l, p) => (p.limit <= 1 ? `Enter ${l}.` : `${cap(l)} must be at least ${p.limit} characters.`),
  maxLength: (l, p) => `${cap(l)} must be ${p.limit} characters or fewer.`,
  format: (l, p) => (p.format === "email" ? "Enter an email address like [email protected]." : `Check the format of ${l}.`),
  pattern: (l) => `Check ${l}.`,
  minimum: (l, p) => `${cap(l)} must be ${p.limit} or more.`,
  type: (l, p) => (p.type === "integer" || p.type === "number" ? `Enter ${l} as a number.` : `Check ${l}.`),
};
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);

export function validateContact(values: unknown): Record<string, string> {
  if (validate(values)) return {};
  const errors: Record<string, string> = {};
  for (const e of validate.errors ?? []) {
    if (e.keyword === "additionalProperties") continue;       // a client bug, not a user error
    const path = fieldPath(e);
    const t = templates[e.keyword];
    errors[path] ??= t ? t(labelFor(path), e.params) : `Check ${labelFor(path)}.`;
  }
  return errors;
}

Step-by-step walkthrough

  1. Compile each schema once. ajv.compile is the expensive step; cache the resulting function per schema (by $id for runtime schemas).
  2. Enable allErrors and add formats. Forms need every error at once, and format: "email" must actually validate.
  3. Map instancePath to form paths. It is a JSON Pointer; unescape ~1 and ~0 and join with dots, following normalizing nested field error paths.
  4. Fix required paths. For missing properties Ajv reports the parent’s path; append params.missingProperty so the error lands on the field.
  5. Replace messages with templates keyed by keyword. Use the schema’s title (or a separate labels map) to name the field. Ajv’s message is for developers; ajv-i18n offers translated defaults if you want a starting point.
  6. Precompile for production when possible. Ajv’s standalone mode generates validation modules at build time, removing the runtime compiler from the bundle and the need for unsafe-eval in CSP.

Why precompiling matters for the browser

Ajv’s speed comes from generating specialised JavaScript for each schema at runtime with new Function. In the browser, that has two costs: the compiler itself is a large part of Ajv’s bundle, and new Function requires the unsafe-eval CSP source, which security-conscious sites forbid. When schemas are known at build time — the common OpenAPI case — standalone code generation produces small, dependency-light validation modules that run under a strict CSP. Runtime compilation remains necessary only for schemas that truly arrive at runtime, such as CMS-built forms, and even then it can move to a server endpoint that returns validation results.

Runtime compile or precompile? If schemas are known at build time, precompile them with Ajv standalone code generation, which keeps the compiler out of the bundle and works with a strict CSP. If schemas arrive at runtime and the CSP allows unsafe-eval, compile them at runtime in the browser and cache the functions. If schemas arrive at runtime and the CSP forbids unsafe-eval, validate on the server or use an interpreter-based validator that does not generate code. Schemas known at build time? Precompile (standalone) yes no CSP allows unsafe-eval? Compile at runtime, cache yes no Server-side or interpreter validator No code generation in the page.

Failure modes and edge cases

1. additionalProperties: false rejects form extras

Forms often carry fields the API does not (a confirm-password field, UI flags). With additionalProperties: false, those produce errors. Strip UI-only fields before validating, and treat any remaining additionalProperties error as a developer bug, not a message for users.

2. Coercion and empty strings

coerceTypes: true turns "" into 0 for numbers and null-able types into null — the same trap as other coercion. Prefer converting form strings explicitly, as in coercing form strings with Zod preprocess and coerce, then validating typed data.

3. Conditional schemas

if/then/else, oneOf and anyOf produce many errors from alternative branches, most irrelevant to the user. Prefer if/then over oneOf for conditional required fields, and filter oneOf/anyOf wrapper errors when leaf errors exist.

4. Draft versions

Ajv’s default class targets draft-07; JSON Schema 2019-09 and 2020-12 need Ajv2019 or Ajv2020. OpenAPI 3.1 uses 2020-12. A schema compiled with the wrong class fails on unknown keywords under strict.

5. Unicode in patterns

JSON Schema pattern uses ECMA-262 regex syntax; Ajv compiles patterns with the u flag by default in recent versions. Patterns written for other engines (Python, PCRE) may not behave the same — test them in the browser.

A required-field error from Ajv to the form The form calls the compiled validate function with an address object lacking a postcode. Ajv reports an error with keyword required, instancePath slash address and params missingProperty postcode. The mapper converts the pointer to address and appends postcode, giving address dot postcode. It looks up the subschema title your postcode and applies the required template, producing enter your postcode, which the form shows under the postcode input. Form Ajv validate Error mapper validate({ address: {} }) required @ /address, missingProperty: postcode address.postcode: "Enter your postcode."

Verification checklist


Frequently Asked Questions

Should I convert JSON Schema to Zod instead?

Converters exist and are useful when the schema is known at build time and you want Zod’s ergonomics in the frontend. For runtime schemas, or to guarantee exactly the same semantics as other services validating the same JSON Schema, validate the JSON Schema directly.

How do I get field labels from JSON Schema?

Use title on each property, as above, or a separate UI schema (as form builders often do) keyed by JSON Pointer. Do not derive labels from property names; postal_code makes a poor label.

Is Ajv fast enough for keystroke validation?

Compiled Ajv validators are among the fastest JavaScript validators available. The costs to watch are compilation (do it once) and error mapping on large forms; validate the changed field’s subschema where possible.


Related

← Choosing a Schema Validation Library