JSON.parse tells you whether text is JSON. It does not tell you whether the JSON has the fields your code expects. A JSON Schema describes that shape, and Ajv is a JavaScript library that checks data against one. This guide is a quick start: install Ajv, pick the right class for your schema's draft, compile once, read the errors, and avoid the strict-mode and format errors you are likely to hit first.
Every snippet, output and error message below was run on Node.js 24.21.0 with ajv 8.20.0, ajv-formats 3.0.1 and express 5.3.0. The example schemas were made by the JSON to JSON Schema generator from sample JSON, so you can make your own the same way and follow along. If your JSON does not parse at all, fix that first; the format and validate JSON guide covers syntax checks.
The example schema
This sample is two users from an API response:
[
{
"id": 1024,
"email": "ada@example.com",
"name": "Ada Lovelace",
"nickname": null,
"createdAt": "2026-11-04T09:30:00Z",
"roles": ["admin", "editor"]
},
{
"id": 1025,
"email": "grace@example.com",
"name": "Grace Hopper",
"nickname": "Amazing Grace",
"createdAt": "2026-11-03T14:05:00Z",
"roles": [],
"team": "compilers"
}
]Pasted into the schema generator with the default draft 2020-12 and Detect string formats turned on, it gives this schema, saved as users.schema.json:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"email": { "type": "string", "format": "email" },
"name": { "type": "string" },
"nickname": { "type": ["string", "null"] },
"createdAt": { "type": "string", "format": "date-time" },
"roles": { "type": "array", "items": { "type": "string" } },
"team": { "type": "string" }
},
"required": ["id", "email", "name", "nickname", "createdAt", "roles"]
}
}The generator writes one keyword per line; it is condensed here to save space. Three things in it matter for Ajv:
requiredlists the keys found in every sample.teamappears in only one, so it is optional.nicknamewasnullin one sample and a string in the other, so its type is the union["string", "null"].formatis only a name. Ajv needs theajv-formatsplugin to check it.
Install
npm install ajv ajv-formatsThat installed ajv@8.20.0 and ajv-formats@3.0.1. Both are MIT licensed.
Draft 2020-12 or draft-07: pick the right class
The default export of ajv supports draft-07 (Ajv's docs label draft-07 as the default). A draft 2020-12 schema needs the class from ajv/dist/2020. If you use the wrong one, Ajv cannot find the meta-schema named in $schema and compile throws:
import Ajv from "ajv";
import { readFileSync } from "node:fs";
const schema = JSON.parse(readFileSync("users.schema.json", "utf8"));
const ajv = new Ajv();
ajv.compile(schema);
// Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"Schema's $schema | ES module import | CommonJS |
|---|---|---|
https://json-schema.org/draft/2020-12/schema | import Ajv2020 from "ajv/dist/2020.js" | require("ajv/dist/2020") |
http://json-schema.org/draft-07/schema# | import Ajv from "ajv" | require("ajv") |
Ajv's docs write the import as ajv/dist/2020. In a plain Node.js ES module (.mjs) that path fails with ERR_MODULE_NOT_FOUND, because ajv 8.20.0 has no exports map and Node does not add the extension; write ajv/dist/2020.js there. CommonJS require works without it. The docs also warn that draft 2020-12 is not backwards compatible, so you cannot mix 2020-12 and older schemas in one Ajv instance.
If your project already uses draft-07, choose Draft-07 in the generator. For this sample the only change is the $schema line, and the default Ajv class with ajv-formats accepts the sample data. The generator leaves out "format": "uuid" for draft-07, because that format was added in draft 2019-09.
Compile once, validate many times
import Ajv2020 from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { readFileSync } from "node:fs";
const schema = JSON.parse(readFileSync("users.schema.json", "utf8"));
const ajv = new Ajv2020();
addFormats(ajv);
const validate = ajv.compile(schema); // compile once, at startup
const sample = JSON.parse(readFileSync("sample.json", "utf8"));
console.log(validate(sample)); // true
console.log(validate.errors); // nullcompile turns the schema into a JavaScript function. Ajv's getting-started guide says compilation is "relatively slow, so you need to make sure that you compile schemas only once and re-use compiled validation functions". Ajv caches compiled functions with the schema object as the key, so a schema object you parse again on every request is compiled again on every request. Create the Ajv instance and the validate function at module level, not inside a request handler.
Read validate.errors
validate(data) returns true or false. On failure the details are in validate.errors, which is overwritten by the next call. This user has five problems:
const bad = [
{
id: "1026",
email: "not-an-email",
name: "Alan Turing",
createdAt: "2026-11-04",
roles: ["admin", 7],
},
];
console.log(validate(bad)); // false
console.log(validate.errors);By default Ajv stops at the first error:
[
{
instancePath: '/0',
schemaPath: '#/items/required',
keyword: 'required',
params: { missingProperty: 'nickname' },
message: "must have required property 'nickname'"
}
]| Field | What it tells you |
|---|---|
instancePath | Where in the data, as a JSON Pointer. '/0' is the first array item; '' is the root. |
keyword | The schema keyword that failed: required, type, format and so on. |
params | Details for that keyword, such as missingProperty or the expected type. Use these in code, not the message text. |
message | A short English message for logs and API responses. |
schemaPath | Where in the schema the failing keyword is. |
Note what failed: nickname is required because it was in every sample, even though one sample had null. The key must be present; null is allowed as its value. If an API leaves the key out instead of sending null, include a sample like that before generating the schema, or remove the key from required.
allErrors: report every problem
Pass allErrors: true to collect all errors, which is what you want for a form or an API error response:
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);
if (!validate(bad)) {
for (const e of validate.errors) {
console.log(`${e.instancePath || "(root)"} ${e.message}`);
}
}
console.log(ajv.errorsText(validate.errors));/0 must have required property 'nickname'
/0/id must be integer
/0/email must match format "email"
/0/createdAt must match format "date-time"
/0/roles/1 must be string
data/0 must have required property 'nickname', data/0/id must be integer, data/0/email must match format "email", data/0/createdAt must match format "date-time", data/0/roles/1 must be string"1026" fails integer because Ajv does not convert types unless you ask it to. 2026-11-04 fails date-time because it has no time. The optional team key is missing and causes no error.
There is a cost. Ajv's security docs have a warning headed "Do NOT use allErrors in production": their advice against slow validation of untrusted data only works when allErrors is off, because with it on, validation continues after the first error. Weigh friendlier error lists against that for public endpoints.
Formats need ajv-formats
Without the plugin, a schema with format does not compile in Ajv's default strict mode:
const ajv = new Ajv2020();
ajv.compile(schema);
// Error: unknown format "email" ignored in schema at path "#/items/properties/email"Call addFormats(ajv) before compile, as in the examples above. The generator only adds format when you turn on Detect string formats and every string at that place matches; it can add date-time, date, uuid, email and uri, which are all formats ajv-formats checks.
Strict mode and union types
Ajv's strict mode is on by default. One of its rules, strictTypes, objects to a type array with more than one non-null type. A type plus null, like nickname above, is allowed, so the users schema compiles without warnings. The generator makes a real union when samples disagree, for example a postcode sent as a string in one order and a number in another:
[
{ "orderId": 501, "postcode": "12000", "note": null },
{ "orderId": 502, "postcode": 12000, "note": "Leave at the door" }
]The generated schema has "postcode": { "type": ["string", "integer"] }. With default options, compile succeeds and prints this warning to stderr:
strict mode: use allowUnionTypes to allow union type keyword at "#/items/properties/postcode" (strictTypes)The default for strictTypes is to log. With new Ajv2020({ strict: true }) the same text is thrown as an error instead:
Error: strict mode: use allowUnionTypes to allow union type keyword at "#/items/properties/postcode" (strictTypes)If the union is what you mean, allow it. The option only silences the check: with or without it, the compiled function accepted 12000 and "12000" and rejected true:
const ajv = new Ajv2020({ strict: true, allowUnionTypes: true });
const validate = ajv.compile(schema);
console.log(validate([{ orderId: 503, postcode: 12000, note: null }])); // trueOften the better fix is the data: a postcode should probably always be a string. Change the schema to "type": "string" and the warning goes away.
Extra keys and additionalProperties
A generated schema accepts keys it has never seen, because JSON Schema allows extra properties unless the schema says otherwise. A user with an extra isAdmin: true passes the schema above. Turn on No extra keys (additionalProperties: false) in the generator and the same data fails:
[
{
instancePath: '/0',
schemaPath: '#/items/additionalProperties',
keyword: 'additionalProperties',
params: { additionalProperty: 'isAdmin' },
message: 'must NOT have additional properties'
}
]Validate a request body in Express
The generated schema describes an array of users, so its items is the schema for one user. This server compiles it once and returns a 400 with every error. It uses allErrors for a complete error list; see the warning above before doing the same on a public endpoint:
import express from "express";
import Ajv2020 from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { readFileSync } from "node:fs";
const usersSchema = JSON.parse(readFileSync("users.schema.json", "utf8"));
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
// The generated schema describes an array of users; "items" is one user.
const validateUser = ajv.compile(usersSchema.items);
const app = express();
app.use(express.json());
app.post("/users", (req, res) => {
if (!validateUser(req.body)) {
return res.status(400).json({
error: "Invalid request body",
details: validateUser.errors.map((e) => ({
path: e.instancePath,
message: e.message,
})),
});
}
res.status(201).json({ ok: true });
});
app.listen(3000);A request with a bad body (response headers left out):
curl -i -X POST localhost:3000/users -H 'Content-Type: application/json' \
-d '{"id":"1026","email":"alan@example","name":"Alan Turing","roles":[]}'
HTTP/1.1 400 Bad Request
{"error":"Invalid request body","details":[{"path":"","message":"must have required property 'nickname'"},{"path":"","message":"must have required property 'createdAt'"},{"path":"/id","message":"must be integer"},{"path":"/email","message":"must match format \"email\""}]}A complete, valid user gets HTTP/1.1 201 Created. Read the errors right after the call: the next request to the same function replaces them. Returning Ajv's messages tells clients exactly what to fix; if your schema is private, map them to your own messages first.
Ajv in the browser and Content-Security-Policy
Ajv compiles a schema by generating JavaScript source and running it with the Function constructor (in lib/compile/index.ts: new Function(`${N.self}`, `${N.scope}`, sourceCode)). That is a form of eval, and Ajv's security docs say so directly:
When using Ajv in a browser page with enabled Content Security Policy (CSP),
script-srcdirective must include'unsafe-eval'.
The same page says unsafe-eval is not recommended in a secure CSP, because it can open the page to cross-site scripting. So if your site has a CSP without 'unsafe-eval', Ajv cannot compile schemas in that page. The browser case was not tested for this guide, but you can see the same kind of failure in Node.js, which can switch off code generation from strings. The validate-users.mjs script from the "Compile once" section then fails:
node --disallow-code-generation-from-strings validate-users.mjs
# Ajv first logs "Error compiling schema, function code: ..." with the
# generated source, then throws:
EvalError: Code generation from strings disallowed for this contextAjv's answer is standalone validation code: compile the schema at build time and ship the generated function, so the page never needs the Function constructor. A build script for the users schema:
import Ajv2020 from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import standaloneCode from "ajv/dist/standalone/index.js";
import { readFileSync, writeFileSync } from "node:fs";
const schema = JSON.parse(readFileSync("users.schema.json", "utf8"));
const ajv = new Ajv2020({ code: { source: true } });
addFormats(ajv);
const validate = ajv.compile(schema);
writeFileSync("validate-users.generated.cjs", standaloneCode(ajv, validate));The generated CommonJS file still loads require("ajv-formats/dist/formats") for the format checks, but not Ajv itself. Imported into an ES module and run with --disallow-code-generation-from-strings, it returned true for the sample and false with must match format "email" for a bad email. Ajv also has a code.esm option for ES module output, but with this formats schema the generated file still contained that require call, and importing it failed with ReferenceError: require is not defined in ES module scope, you can use import instead. For a browser you would pass the generated file through your bundler; that step was not tested here.
On the server none of this applies: Node.js allows the Function constructor by default, and Ajv's docs say its validation model "is optimized for server side execution".
Quick reference
| Problem | Fix |
|---|---|
no schema with key or ref "…/draft/2020-12/schema" | Use Ajv2020 from ajv/dist/2020.js |
unknown format "email" ignored in schema | addFormats(ajv) from ajv-formats |
use allowUnionTypes to allow union type keyword | allowUnionTypes: true, or one type in the schema |
| Only one error reported | allErrors: true |
| Slow validation in a request handler | Call compile once at startup |
| Page CSP blocks Ajv | Standalone code generated at build time |
To get a starting schema, paste a few real responses into the JSON to JSON Schema generator, then tighten it by hand: required keys, formats and additionalProperties. To tidy or check the sample JSON first, use the JSON formatter.