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

使用 Zod 验证请求并返回类型化 JSON

解析并验证请求正文和查询参数,返回类型明确且结构良好的错误响应。

使用 Zod 验证请求并返回类型化 JSON 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Next.js 15 Fullstack (App Router + Server Actions) 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

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.

常见问题解答

「使用 Zod 验证请求并返回类型化 JSON」课时是免费的吗?

是的 — 「使用 Zod 验证请求并返回类型化 JSON」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

「使用 Zod 验证请求并返回类型化 JSON」这节课中我会学到什么?

解析并验证请求正文和查询参数,返回类型明确且结构良好的错误响应。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。

「使用 Zod 验证请求并返回类型化 JSON」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?

能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 使用 Web 请求 API 设计 RESTful 路由处理程序
  2. Node 运行时与 Edge 运行时的权衡
  3. 处理程序中的流式响应与 ReadableStream
  4. 使用 Zod 验证请求并返回类型化 JSON
← 返回 Next.js 15 Fullstack (App Router + Server Actions)