0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · Lesson

Request Validation and Typed JSON Responses with Zod

Parse and validate request bodies and query params, returning typed, well-structured error responses.

Request Validation and Typed JSON Responses with Zod is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Validate at the Edge?

In Next.js 15 Route Handlers (app/api/.../route.ts), the request body is just untyped JSON. TypeScript types vanish at runtime, so a client can send anything.

Zod lets you describe the expected shape once and get both:

  • A runtime check that rejects bad input.
  • A static type inferred from the schema, so your handler code is fully typed.

This lesson builds a typed POST handler that validates the body and query params, then returns clean, structured JSON errors.

Defining a Schema

Start by declaring the shape you expect. Zod schemas double as the single source of truth for both validation and types.

Use z.infer to derive a TypeScript type from the schema. There is no duplication: change the schema and the type updates automatically.

import { z } from 'zod';

export const CreateUserSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email address'),
  age: z.number().int().positive().optional(),
});

// Inferred type — fully typed, no duplication
export type CreateUserInput = z.infer<typeof CreateUserSchema>;

Parsing the Request Body

Inside a Route Handler, read JSON with await request.json(). Wrap it in a try/catch because malformed JSON throws before Zod ever runs.

Use schema.safeParse() instead of parse(). safeParse never throws — it returns a result object you can branch on.

import { NextRequest, NextResponse } from 'next/server';
import { CreateUserSchema } from './schema';

export async function POST(request: NextRequest) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: 'Invalid JSON body' },
      { status: 400 },
    );
  }

  const result = CreateUserSchema.safeParse(body);
  // ...handle result
}

safeParse: Success vs Failure

safeParse returns a discriminated union:

  • On success: { success: true, data } where data is the typed, validated value.
  • On failure: { success: false, error } where error is a ZodError.

TypeScript narrows the type after you check result.success, so result.data is only accessible on the success branch.

const result = CreateUserSchema.safeParse(body);

if (!result.success) {
  return NextResponse.json(
    { error: 'Validation failed', issues: result.error.issues },
    { status: 422 },
  );
}

// result.data is now typed as CreateUserInput
const user = result.data;
console.log(user.email);

Shaping a Useful Error Response

Dumping the raw ZodError works but is noisy. A cleaner API returns a flat map of field to messages. Zod's error.flatten() gives you fieldErrors and formErrors ready for the client.

Pick a consistent status: 400 for unparseable input, 422 (Unprocessable Entity) for well-formed JSON that fails validation.

if (!result.success) {
  const { fieldErrors, formErrors } = result.error.flatten();
  return NextResponse.json(
    {
      error: 'Validation failed',
      fieldErrors, // { email: ['Invalid email address'] }
      formErrors,  // top-level errors
    },
    { status: 422 },
  );
}

Validating Query Parameters

Query params arrive as strings via request.nextUrl.searchParams. Use z.coerce to convert and validate in one step — for example turning ?page=2 into a real number.

Provide .default() values so missing params do not break the handler.

import { z } from 'zod';
import { NextRequest, NextResponse } from 'next/server';

const QuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

export async function GET(request: NextRequest) {
  const params = Object.fromEntries(request.nextUrl.searchParams);
  const result = QuerySchema.safeParse(params);

  if (!result.success) {
    return NextResponse.json(
      { error: 'Invalid query', fieldErrors: result.error.flatten().fieldErrors },
      { status: 400 },
    );
  }

  const { page, limit } = result.data; // numbers, defaulted
  return NextResponse.json({ page, limit });
}

Typed JSON Responses

NextResponse.json() is generic. Pass a type argument to lock down the success payload shape so your handler and your client stay in sync.

Define a shared response type and reuse it on both the server and the frontend fetch call.

import { NextResponse } from 'next/server';

type UserResponse = {
  id: string;
  name: string;
  email: string;
};

function ok(user: UserResponse) {
  // The generic enforces the body matches UserResponse
  return NextResponse.json<UserResponse>(user, { status: 201 });
}

A Reusable Validation Helper

Repeating the parse/branch logic in every handler gets tedious. Extract a small helper that validates a body against any schema and returns a typed result.

This keeps each Route Handler focused on business logic while centralizing the error-response format.

import { z } from 'zod';
import { NextResponse } from 'next/server';

export async function parseBody<T extends z.ZodTypeAny>(
  request: Request,
  schema: T,
): Promise<
  | { ok: true; data: z.infer<T> }
  | { ok: false; response: NextResponse }
> {
  let raw: unknown;
  try {
    raw = await request.json();
  } catch {
    return { ok: false, response: NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) };
  }
  const result = schema.safeParse(raw);
  if (!result.success) {
    return {
      ok: false,
      response: NextResponse.json(
        { error: 'Validation failed', fieldErrors: result.error.flatten().fieldErrors },
        { status: 422 },
      ),
    };
  }
  return { ok: true, data: result.data };
}

Using the Helper in a Handler

With parseBody in place, a handler becomes short and readable. Validate, early-return on failure, then work with fully typed data.

This pattern scales: every endpoint follows the same shape, so error responses are consistent across your whole API.

import { NextRequest } from 'next/server';
import { CreateUserSchema } from './schema';
import { parseBody } from '@/lib/parse-body';
import { NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const parsed = await parseBody(request, CreateUserSchema);
  if (!parsed.ok) return parsed.response;

  const { name, email } = parsed.data; // typed
  const user = { id: crypto.randomUUID(), name, email };
  return NextResponse.json(user, { status: 201 });
}

Transforming and Refining

Zod can do more than reject — it can normalize. Use .transform() to reshape values (trim, lowercase) and .refine() for cross-field rules that a single field check cannot express.

The output type reflects transforms, so downstream code sees the cleaned data.

import { z } from 'zod';

const SignupSchema = z
  .object({
    email: z.string().email().transform((s) => s.toLowerCase().trim()),
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((d) => d.password === d.confirm, {
    message: 'Passwords do not match',
    path: ['confirm'],
  });

type Signup = z.infer<typeof SignupSchema>;

Validation Logic You Can Actually Run

Strip away Next.js and the core idea is pure data validation. Here is a self-contained TypeScript program that mirrors the same parse-and-branch flow using Zod, with no server involved.

It shows both a valid and an invalid payload and prints the structured error map.

import { z } from 'zod';

const Schema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.coerce.number().int().positive().optional(),
});

function validate(input: unknown) {
  const result = Schema.safeParse(input);
  if (!result.success) {
    return { status: 422, body: { fieldErrors: result.error.flatten().fieldErrors } };
  }
  return { status: 201, body: result.data };
}

console.log(JSON.stringify(validate({ name: 'Ada', email: 'ada@example.com', age: '30' })));
console.log(JSON.stringify(validate({ name: '', email: 'nope' })));

Quick Check

You are writing a POST Route Handler that validates the JSON body with a Zod schema. Which approach best returns a typed, structured error response without crashing on bad input?

Recap

You built a typed, validated Next.js 15 API endpoint:

  • Schema first: define a Zod schema and derive the type with z.infer.
  • safeParse, not parse: branch on result.success so nothing throws.
  • Structured errors: use error.flatten(); return 400 for bad JSON, 422 for validation failures.
  • Query params: coerce strings with z.coerce and supply .default() values.
  • Typed responses: NextResponse.json<T>() keeps server and client in sync.
  • Reuse: a parseBody helper centralizes the parse/error pattern across every handler.

The result is an edge API that is safe at runtime and fully typed at compile time.

Frequently asked questions

Is the “Request Validation and Typed JSON Responses with Zod” lesson free?

Yes — the full text of “Request Validation and Typed JSON Responses with Zod” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.

What will I learn in “Request Validation and Typed JSON Responses with Zod”?

Parse and validate request bodies and query params, returning typed, well-structured error responses. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?

No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Request Validation and Typed JSON Responses with Zod” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?

Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Designing RESTful Route Handlers with the Web Request API
  2. Node Runtime vs Edge Runtime Tradeoffs
  3. Streaming Responses and ReadableStream in Handlers
  4. Request Validation and Typed JSON Responses with Zod
← Back to Next.js 15 Fullstack (App Router + Server Actions)