Validation + Consistent Responses: Making Your API Feel “Professional”

Welcome back! In this lesson, we’ll take your Task Manager API from “it works” to “it’s predictable and safe to use.” That means validating everything that comes in (so bad input can’t sneak through) and standardizing everything that goes out (so every client knows exactly what shape to expect).

You’ll do this in a very “Codex CLI” way: small, targeted prompts that touch a couple of files at a time, with clear rules about behavior and response shape. By the end, your /api/tasks, /api/tasks/[id], and /api/tasks/filter endpoints will all speak the same JSON “language,” and they’ll reject invalid requests with helpful, consistent error payloads.

Previously…

In the previous lesson, you focused on polishing the API surface: introducing response helpers and refactoring routes to use them so every response had a consistent envelope. Now we’ll build on that foundation by adding Zod-powered request validation (instead of ad-hoc checks), and we’ll apply it across create/update flows so the backend is just as strict and predictable as the response format.

How we’ll use Codex CLI in this lesson

The key habit is to treat Codex like a teammate who needs constraints. For each change, you’ll give a prompt that:

  • lists the exact files Codex may modify,
  • describes the exact response shape and status codes,
  • and explicitly says “don’t touch anything else.”

For example:

Codex, modify only src/lib/validation/taskSchemas.ts.

Implement Zod schemas for task create/update: createTaskSchema, putTaskSchema, and patchTaskSchema.

Keep the field rules: title/content required non-empty strings, completed default false, dueDate optional but must parse as a date string.

Do not modify any other files. Show the full updated file.

That “only these files” pattern is what keeps vibe coding from turning into mystery refactors.

Defining task rules once with Zod schemas

Validation is easiest to maintain when you define it one time, then reuse it everywhere. In this project, that “single source of truth” for task input validation lives in src/lib/validation/taskSchemas.ts.

  • createTaskSchema defines the exact shape your API accepts when creating tasks: required title/content, a boolean completed (defaulting to false), and an optional dueDate that must be parseable as a date string. This keeps your API strict without needing repetitive “if/else validation” in every route.

  • .refine(...) is doing the “date sanity check.” Instead of trusting that a string looks like a date, it ensures Date.parse(...) doesn’t fail—so "not-a-date" can’t slip in and break later logic.

  • putTaskSchema and share the same base rules, but express different update semantics: PUT requires the full object, while PATCH makes every field optional via . This mirrors common REST expectations and keeps your update logic consistent.

Standardizing success + error payloads with response helpers

Once your input is trustworthy, the next professional touch is making output predictable. The helpers in src/lib/responses.ts ensure every JSON response is wrapped consistently, with a timestamp included for debugging and tracing.

  • createSuccessResponse(...) guarantees that every successful call returns { data, meta }, even if data is an array, object, or a single primitive. This means clients don’t have to guess whether they’ll receive “raw JSON” or a wrapped payload.
  • createErrorResponse(...) guarantees that errors always return { error, meta }, with the HTTP status code you choose. That makes it easy to standardize client-side error handling and avoid “sometimes it’s a string, sometimes it’s an object.”
  • The meta.timestamp field provides lightweight observability without adding a logging framework. When you’re testing with a UI or curl, that timestamp helps confirm you’re seeing a fresh response.
Validating creates in the collection route

Now we apply those building blocks to the API. The collection route lives at src/app/api/tasks/route.ts, and it handles:

  • GET /api/tasks (list tasks)
  • POST /api/tasks (create task, with Zod validation)

This route also opts out of caching with export const dynamic = 'force-dynamic', which is important for endpoints backed by changing data.

  • GET awaits getAllTasks() before returning, ensuring the response contains actual task data (not a pending Promise). It then wraps that array in createSuccessResponse(...) so the client always gets { data: [...], meta: ... }.
  • POST validates the request body using safeParse, which avoids throwing and instead returns a result. That makes the happy-path clean, and it makes invalid input easy to convert into a with a structured error payload.
Codex CLI prompt you’d use for this file

Codex, modify only src/app/api/tasks/route.ts.
Ensure GET awaits getAllTasks() before responding, and wrap the result using createSuccessResponse(tasks).
Ensure POST validates with createTaskSchema.safeParse, returns createErrorResponse(parsed.error.format(), 400) on failure, and returns createSuccessResponse(newTask, 201) on success.
Do not change any other files. Show the full updated file.

Validating updates and handling route params safely

Item routes are where APIs often get messy—different status codes, different “not found” behaviors, and inconsistent update rules. In this project, src/app/api/tasks/[id]/route.ts keeps it consistent and also includes a small helper to safely resolve params, even if they arrive as a Promise in some runtimes.

  • resolveParams(...) is a small “defensive programming” helper: it treats params as either a plain object or a Promise and normalizes it to a concrete { id }. This keeps the handler logic stable even if the framework changes how it provides route context.
  • GET converts the route id to a number and asks the service layer for the task. If it’s missing, the route returns a clean 404 using the standard error envelope, so clients can handle “not found” uniformly.
Validating query parameters in the filter route

One more common pitfall: query params are strings, and they’re easy to mishandle. The filter route at src/app/api/tasks/filter/route.ts validates the query param strictly and still uses the same response helpers.

  • The handler enforces that completed is present and only allows 'true' or 'false'. This is intentionally strict—without it, clients could accidentally send completed=maybe and get confusing behavior.
  • The conversion to boolean happens after validation, so downstream code can treat completed as a real boolean and avoid defensive checks everywhere else.
  • Even though this endpoint is “different” (query-based filtering), it still returns the same success/error envelope, so client code stays simple.
Recap: what you now have (and why it matters)

After this lesson, your backend has two major “production readiness” upgrades:

  • Zod validation as a shared contract (schemas are defined once and reused), so invalid data is rejected early with helpful field-level errors.

Nextjs-frontend-final

  • A consistent response envelope across endpoints—{ data, meta } on success, { error, meta } on failure—so any client can integrate without guessing response shape.

Nextjs-backend-final

Most importantly, you achieved this in a “vibe coding” workflow that still feels professional: small Codex prompts, clear boundaries, and code that stays predictable as the project grows.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal