A Unified Approach to validation across APIs, Queues & Jobs with Zod
Validation can help users enter data in the correct format when interacting with front-end applications. This prevents them from providing empty values, invalid emails, or other common input mistakes.
Once data leaves the browser we still need to ensure that the data remains valid:
- What happens when services communicate asynchronously?
- How do we prevent bad data from breaking downstream systems?
Cloud applications don’t just receive requests from web forms. Data comes from APIs, message queues, scheduled tasks, or WebSockets—each introducing the risk of incomplete, malformed, or even malicious data.
Server-side validation is critical—not just for security, but for maintaining data integrity across the system.
With Zod, we can define strict validation rules once and enforce them across APIs, events, and background jobs.
Defining a validation schema with Zod
A Zod schema acts as a contract for how data should look. The example below defines a userSchema
, which establishes clear rules for incoming user data.
import { z } from 'zod'const userSchema = z.object({name: z.string().min(2, 'Name must be at least 2 characters'),email: z.string().email('Invalid email format'),age: z.number().min(18, 'Must be at least 18 years old'),})
This schema ensures that any user data passed into the application follows these strict rules.
Validating data
Once the schema is defined, we can use .safeParse()
to validate incoming data before using it in our application.
const validUser = userSchema.safeParse({name: 'Alice',email: 'alice@example.com',age: 25,})
What happens here?
- The
safeParse()
method checks if the input matches theuserSchema
. - If validation passes, the
validUser
object will contain the parsed data. - If validation fails, Zod returns an object containing detailed error messages instead of throwing an exception.
Alternatively, use .parse(), which throws an error that can be handled appropriately.
Applying validation to an API endpoint
Now, let’s use our schema apply server-side validation to an API.
The following API route:
- Extracts the request body from the incoming request
- Validates the data against
userSchema
- Returns a 400 error if validation fails
import { api } from '@nitric/sdk'import { z } from 'zod'const userSchema = z.object({name: z.string().min(2, 'Name must be at least 2 characters'),email: z.string().email('Invalid email format'),age: z.number().min(18, 'Must be at least 18 years old'),})const usersApi = api('users')usersApi.post('/', async (ctx) => {const result = userSchema.safeParse(ctx.req.json()) // Validate requestif (result.success) {ctx.res.json({ message: 'User validated.', result })} else {ctx.res.status = 400ctx.res.json(result.error)}})
Why this matters:
- If the request body is valid, the API processes the request as usual.
- If invalid, the response includes detailed validation errors, ensuring that only properly formatted data is accepted.
Example invalid request:
{"name": "test","email": "test@test.com","age": 12}
Expected response:
{"issues": [{"code": "too_small","minimum": 18,"type": "number","inclusive": true,"exact": false,"message": "Must be at least 18 years old","path": ["age"]}],"name": "ZodError"}
APIs aren’t the only place validation matters. Cloud applications move data through multiple services—including message queues and scheduled tasks.
Validating and processing messages in a queue
If we don’t validate messages before processing them, bad data can propagate throughout the system.
Let’s say we have an orders
queue where payment services publish transaction messages. To maintain integrity, we must validate each order before processing it.
Defining the validation schema
import { api, queue, schedule } from '@nitric/sdk'import { z } from 'zod'const orderSchema = z.object({orderId: z.string().uuid(),amount: z.number().positive(),userId: z.string().min(1),})
Key Rules:
orderId
must be a valid UUIDamount
must be a positive numberuserId
must be a string with at least one character
This schema ensures that invalid orders never enter the system.
Applying validation in a queue handler
Next, we define an API that accepts orders and enqueues them only if they pass validation.
const orderApi = api('orders')const ordersQueue = queue('orders').allow('dequeue', 'enqueue')const orderSchema = z.object({orderId: z.string().uuid(),amount: z.number().positive(),userId: z.string().min(1),})orderApi.post('/', async (ctx) => {const result = orderSchema.safeParse(ctx.req.json())if (result.success) {await ordersQueue.enqueue(result.data)ctx.res.json({message: `Adding order with id: ${result.data.orderId} to queue`,})} else {ctx.res.status = 400ctx.res.json(result.error)}})
Breakdown:
- The request body is validated against
orderSchema
- If the data is valid, it gets enqueued for processing
- If invalid, a
400
error response is returned
This prevents bad data from ever reaching the queue.
Processing the Queue
To ensure only valid data is processed, we apply validation again when dequeuing orders.
schedule('process-transactions').every('5 minutes', async (ctx) => {console.log(`Processing at ${new Date().toLocaleString()}`)const tasks = await ordersQueue.dequeue()await Promise.all(tasks.map(async (task) => {const result = orderSchema.safeParse(task.payload)if (result.success) {console.log(`Processing order ${result.data.orderId} for user ${result.data.userId}`,)await task.complete()} else {console.error(`Invalid order message:`, result.error)}}),)})
What This Does:
- Every 5 minutes, the function processes all messages in the queue
- Each message is validated before being processed
- Invalid messages are logged and ignored to prevent errors in downstream services
Conclusion
Validation isn't just about catching errors, it’s about building confidence in how data moves through an application.
By integrating Zod into a Nitric application, we:
- Ensure APIs only accept well-formed requests
- Prevent malformed messages from breaking queue processing
- Guarantee background jobs run with predictable data
- Validate real-time connections before they interact with the system
Now that you've established server-side validation, the next step is handling errors effectively, some errors and failures will require retries, while others should be logged and flagged for manual review.