HTTP APIs
Take a filter in a GET request, read an id from the route, turn errors into responses and log them readably.
These recipes use the web's standard Request and Response, which Node.js has as globals. With
another framework, read the request and write the response its own way: the rest is the same.
A GET with the filter in q
A query string only carries strings, and mz.filter doesn't turn '1' into a number. Send the
filter as JSON in one parameter, q, and decode it with the JSON codec from
Zod's docs:
import { MongoServerError, ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'
const postSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
title: z.string()
.min(1),
slug: z.string(),
status: z.enum(['draft', 'published'])
.default('draft'),
tags: z.string()
.array()
.default([]),
authorId: mz.objectId(),
createdAt: mz.date(),
updatedAt: mz.date()
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema,
timestamps: true,
indexes: (index) => [
index({ slug: 1 }, { unique: true })
]
})
const jsonCodec = <T extends z.core.$ZodType>(schema: T) =>
z.codec(z.string(), schema, {
decode: (json, ctx) => {
try {
return JSON.parse(json)
} catch {
ctx.issues.push({ code: 'invalid_format', format: 'json', input: json })
return z.NEVER
}
},
encode: (value) => JSON.stringify(value)
})
const listPostsQuery = z.object({
q: jsonCodec(
mz.filter(postSchema, {
authorId: ['$eq'],
tags: ['$in'],
createdAt: ['$gte', '$lt']
})
)
.default({}),
limit: z.coerce.number()
.int()
.min(1)
.max(100)
.default(20)
})
// GET /posts?q={"tags":{"$in":["mongodb"]},"createdAt":{"$gte":"2026-01-01T00:00:00Z"}}&limit=10
export async function listPosts(request: Request) {
const query = listPostsQuery.safeParse(Object.fromEntries(new URL(request.url).searchParams))
if (!query.success) return Response.json(z.flattenError(query.error), { status: 400 })
const { q, limit } = query.data
const posts = await PostsModel.find(
{ $and: [q, { status: 'published' }] },
{ sort: { createdAt: -1 }, limit }
)
return Response.json(posts)
}JSON keeps numbers, booleans, null and nested operators, and mz.filter parses each value with
the schema of its field, so the hex of authorId arrives as an ObjectId and the ISO string as a
Date. The handler joins the client's filter with its own scope in an $and, so the client cannot
replace a key of the scope.
A client builds the URL with URLSearchParams, which encodes the JSON:
const params = new URLSearchParams({ q: JSON.stringify({ tags: { $in: ['mongodb'] } }) })
await fetch(`https://blog.example.com/posts?${params}`)For a parameter that is a plain value, such as limit, z.coerce is enough. See
Endpoint inputs for what mz.filter takes.
An id from the route
A route like /posts/:id gives the id as a string. Parse it with mz.objectId(), which takes the
hex and returns an ObjectId:
export async function getPost(id: string) {
const parsed = mz.objectId().safeParse(id)
if (!parsed.success) return Response.json(z.flattenError(parsed.error), { status: 400 })
const post = await PostsModel.findOne({ _id: parsed.data, status: 'published' })
if (!post) return Response.json({ error: 'not found' }, { status: 404 })
return Response.json(post)
}The filter keeps drafts out, as the list does: a draft is reachable by its _id otherwise.
Errors to clients
A ZodError comes from the input of the endpoint or from a write whose data doesn't fit the schema,
and the driver's errors arrive unchanged, such as a duplicate key on the unique index of slug. The
input is parsed apart, so its errors answer 400, and one function turns the errors of the write into
responses:
function errorResponse(error: unknown) {
if (error instanceof MongoServerError && error.code === 11000) {
return Response.json(
{ error: 'already exists', fields: Object.keys(error.keyValue ?? {}) },
{ status: 409 }
)
}
console.error(error instanceof z.ZodError ? z.prettifyError(error) : error)
return Response.json({ error: 'internal error' }, { status: 500 })
}
const createPostBody = postSchema.pick({ title: true, slug: true, tags: true })
export async function createPost(request: Request, userId: ObjectId) {
const body = createPostBody.safeParse(await request.json())
if (!body.success) return Response.json(z.flattenError(body.error), { status: 400 })
try {
const { document } = await PostsModel.insertOne({ ...body.data, authorId: userId })
return Response.json(document, { status: 201 })
} catch (error) {
return errorResponse(error)
}
}The author comes from the session, not from the body: with authorId in createPostBody, a client
could publish as anyone. The 409 names the fields of the index, not their values, which would tell
a client which slug or email exists.
z.flattenError gives the messages of each field, { formErrors, fieldErrors }. The errors of an
update are under their operator, as in { fieldErrors: { $set: ['Too small: …'] } }, because
their path is ['$set', 'title']. For nested objects, z.treeifyError mirrors the schema
(Zod docs).
A ZodError from the write means your own code built data the schema rejects, a bug, so it is
logged and answers 500. Some issues carry the value they are about in input, so return
z.flattenError and never error.issues as they are (see
Security).
Readable errors in logs
The message of a ZodError is the JSON of its issues: log z.prettifyError(error), which gives
one line per issue with its path, as Validation shows.
A cursor of find can be awaited, but it has no .catch(). Await it inside try, or call
.toArray(), which returns a promise.
Common mistakes
- Filters in plain query parameters:
?views=10arrives as the string'10', which a number field rejects. Send the filter as JSON inq. - Passing query or route parameters to the model without parsing them: MongoZod doesn't cast
filters, so a hex string never matches an
ObjectId. Parse withmz.filterormz.objectId(). - Answering 500 to a client's invalid data: parse the input apart and answer 400 to its
ZodError, and 409 for a duplicate key (code11000). - Taking the owner from the body: a field such as
authorIdcomes from the session. Leave it out of the schema the body is parsed with. - Logging
error.messageof aZodError: it's a JSON string. Logz.prettifyError(error).
Last updated on
MongoZod is an independent open-source project. It is not affiliated with, endorsed by or sponsored by MongoDB, Inc. MongoDB® is a registered trademark of MongoDB, Inc.