使用 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 }wheredatais the typed, validated value. - On failure:
{ success: false, error }whereerroris aZodError.
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.successso nothing throws. - Structured errors: use
error.flatten(); return 400 for bad JSON, 422 for validation failures. - Query params: coerce strings with
z.coerceand supply.default()values. - Typed responses:
NextResponse.json<T>()keeps server and client in sync. - Reuse: a
parseBodyhelper 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 反馈 — 无需本地设置。
此课程中的所有课时
- 使用 Web 请求 API 设计 RESTful 路由处理程序
- Node 运行时与 Edge 运行时的权衡
- 处理程序中的流式响应与 ReadableStream
- 使用 Zod 验证请求并返回类型化 JSON