Home / Articles / Replacing as-Casts with Zod Parsing at Every Next.js Data Boundary

This article is published in English.

Replacing as-Casts with Zod Parsing at Every Next.js Data Boundary

Why a TypeScript cast cannot protect you from API drift, and how one Zod schema validates fetch results, forms, Route Handlers and Server Actions in Next.js.

1102 words

Typed components feel safe until production sends a renamed field, a null instead of a string, or an error envelope instead of a user. TypeScript cannot catch that: its types vanish at compile time, while network data exists only at runtime, so as User is an assertion, not a check. This guide shows how a single Zod schema both validates incoming data and produces the TypeScript type, and how to apply it at each boundary of a React and Next.js app: fetch results, forms, Route Handlers and Server Actions.

Untrusted JSON is the real problem

Every payload your code did not construct itself, whether a fetch response, a request body, Server Action input or a webhook, deserves suspicion. Skip the runtime check and you get blind casts, validators that drift from interfaces, and client and server types that disagree. Zod collapses those into one definition: edit the schema and the inferred type changes with it.

Define the schema, derive the type

Start with the import:

import { z } from "zod";

The schema below describes a user profile, z.infer turns it into a TypeScript type, and loadProfile runs the response through parse before returning it.

export const UserProfileSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  displayName: z.string().min(1).optional(),
});export type UserProfile = z.infer<typeof UserProfileSchema>;async function loadProfile(id: string): Promise<UserProfile> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  return UserProfileSchema.parse(data);
}

Compare return data as UserProfile: parsing throws the moment the API breaks the contract, while the cast lets bad data travel until something crashes far from the cause.

In UI code, safeParse is usually better: it returns a result object instead of throwing, so you own the fallback:

const result = UserProfileSchema.safeParse(data);
if (!result.success) {
  console.error(result.error.flatten());
  return null;
}

Forms that hand your submit handler valid data

With zodResolver, React Hook Form validates values before they reach handleSubmit. The file is a client component:

"use client";

The field error messages come from the schema too, which keeps UI feedback and types in step:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";const SignupSchema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z.string().min(8, "At least 8 characters"),
});type SignupValues = z.infer<typeof SignupSchema>;export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupValues>({
    resolver: zodResolver(SignupSchema),
  });  return (
    <form onSubmit={handleSubmit((values) => console.log(values))}>
      <input type="email" {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}
      <button type="submit">Create account</button>
    </form>
  );
}

In a real app, move SignupSchema into a shared module rather than defining it inside the component file, so the server can import the identical rules.

Validating at the Next.js entry points

Route Handlers

A Route Handler needs NextResponse, Zod and the shared profile schema:

import { NextResponse } from "next/server";
import { z } from "zod";
import { UserProfileSchema } from "@/lib/schemas/user";

The handler validates the body with safeParse and returns a 400 with flattened errors on failure. It also parses its own response against UserProfileSchema, so the output honours the client's contract. The hard-coded id stands in for a database insert.

const CreateUserSchema = z.object({
  email: z.string().email(),
  displayName: z.string().min(1).max(80).optional(),
});export async function POST(request: Request) {
  const parsed = CreateUserSchema.safeParse(await request.json());
  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid body", details: parsed.error.flatten() },
      { status: 400 }
    );
  }  const created = {
    id: "11111111-1111-1111-1111-111111111111",
    email: parsed.data.email,
    displayName: parsed.data.displayName,
  };  return NextResponse.json(UserProfileSchema.parse(created), { status: 201 });
}

Server Actions

A Server Action module starts with the directive:

"use server";

The action builds an object from FormData and validates it against the same SignupSchema the form used. Returning ok as a literal type (as const) lets callers narrow the result cleanly:

import { SignupSchema } from "@/lib/schemas/auth";export async function signupAction(formData: FormData) {
  const parsed = SignupSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });  if (!parsed.success) {
    return { ok: false as const, errors: parsed.error.flatten().fieldErrors };
  }  return { ok: true as const };
}

One schema module shared by client and server ends the "valid in the form, rejected by the server" bug. For the same pattern outside Next.js, see sharing one Zod schema across a React frontend and a Node backend.

Habits that keep schemas maintainable

  • Keep schemas together, for example under lib/schemas/*.
  • Derive variants with .extend, .pick and .omit rather than duplicating fields.
  • Keep .transform for small clean-ups like trimming strings or parsing dates, never for hidden business rules.
  • Use z.discriminatedUnion when a payload's shape depends on a status field.
  • Parse environment variables once, at startup.

Composition in practice looks like this. A base schema holds shared fields:

const BaseUser = z.object({
  email: z.string().email(),
  displayName: z.string().optional(),
});

From it, an update schema makes every field optional with .partial(), and a response DTO adds server-owned fields with .extend():

export const UpdateUserSchema = BaseUser.partial();
export const UserDtoSchema = BaseUser.extend({
  id: z.string().uuid(),
  createdAt: z.string().datetime(),
});

One caveat: newer Zod releases introduced top-level formats such as z.email() and z.uuid() and changed how error flattening is exposed. The chained forms shown here may be deprecated in your version, so check the current Zod docs.

Key takeaways

  • Types describe intent; only runtime parsing enforces it at the network edge.
  • Infer TypeScript types from Zod schemas so the two cannot drift apart.
  • Prefer safeParse where you want to handle failure, parse where failure should throw.
  • Reuse one schema for the form, the handler and the action.
  • Pick your most dangerous as cast and give it a schema first.