Email Validation in Next.js 15: Real-Time vs Async Patterns with Zod and Server Actions

hangrydev ·

Your Zod Schema Isn’t Enough

You added z.string().email() to your form schema. Zod checks the format, the form shows a red border, and you call it validated. Feels solid.

It’s not.

That schema happily accepts [email protected], [email protected], and [email protected]. Format checks only stop malformed strings. Every typo’d domain, dead mailbox, and disposable address sails right through to your database, bounces when you try to email them, and drags your sender reputation into the ground.

Real email validation in Next.js 15 requires three layers: client-side Zod for instant feedback, a Server Action for API verification, and an async pattern for batch work. This is the App Router approach. If you’re still on Pages Router, this isn’t your guide.

The Shared Zod Schema

Start with a schema that lives outside your components. Both client and server import it. One source of truth.

// lib/schemas/signup.ts
import { z } from "zod";

export const signupSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z
    .string()
    .min(1, "Email is required")
    .email("That doesn't look like a valid email"),
});

export type SignupInput = z.infer<typeof signupSchema>;

z.string().email() runs a regex designed to catch obviously broken formats. It’s not RFC 5322 compliant and doesn’t try to be. It’ll reject missing @ signs and obvious garbage. That’s its job. Nothing more.

The real question: where do you run the deeper checks?

Layer 1: Client-Side with React Hook Form

React Hook Form with the Zod resolver gives you instant field-level feedback. No round trip. The user sees errors as they type.

// components/signup-form.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, type SignupInput } from "@/lib/schemas/signup";

export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupInput>({
    resolver: zodResolver(signupSchema),
    mode: "onBlur",
  });

  const onSubmit = async (data: SignupInput) => {
    // We'll wire this to a Server Action next
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" {...register("name")} />
        {errors.name && <p role="alert">{errors.name.message}</p>}
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register("email")} />
        {errors.email && <p role="alert">{errors.email.message}</p>}
      </div>
      <button type="submit">Sign up</button>
    </form>
  );
}

mode: "onBlur" validates when the user tabs away from a field. Good UX. mode: "onChange" works too but fires on every keystroke, which feels aggressive for email inputs.

This catches typos like user@ or missing-at-sign.com. But [email protected]? Passes. The domain looks syntactically fine. You need the server for that.

Layer 2: Server Action with API Verification

Server Actions run on the server. They can call external APIs, hit databases, and do things you’d never trust to the client. Real email validation belongs here.

// app/actions/signup.ts
"use server";

import { signupSchema } from "@/lib/schemas/signup";

type ActionState = {
  errors?: {
    name?: string[];
    email?: string[];
    _form?: string[];
  };
  success?: boolean;
};

export async function signupAction(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const raw = {
    name: formData.get("name"),
    email: formData.get("email"),
  };

  // Re-validate on server. Never trust client data.
  const parsed = signupSchema.safeParse(raw);
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }

  // API-level email verification
  const emailCheck = await fetch("https://api.truemail.io/v1/verify", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TRUEMAIL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      email: parsed.data.email,
      checks: ["syntax", "mx"],
    }),
    signal: AbortSignal.timeout(3000),
  });

  if (emailCheck.ok) {
    const result = await emailCheck.json();

    if (result.status === "undeliverable") {
      return {
        errors: { email: ["That email doesn't appear to be deliverable"] },
      };
    }

    if (result.disposable) {
      return {
        errors: { email: ["Disposable email addresses aren't allowed"] },
      };
    }
  }
  // If API times out or fails, accept the email and verify later.
  // Never block a real user because your validation API is slow.

  // Create user, send confirmation, etc.
  return { success: true };
}

Two things worth noticing. First, the Zod schema runs again on the server. Client-side validation is a UX convenience, not a security measure. Anyone can bypass it. Second, the AbortSignal.timeout(3000) sets a hard 3-second ceiling on the API call. If MailCop’s response takes longer, you accept the email and verify async.

Why only syntax and mx checks in the real-time path? Because full SMTP verification takes 500ms to 3 seconds per address. That’s fine for background jobs. It’s a death sentence for form submission UX. The email validation API guide covers this trade-off in depth.

Layer 2.5: Wiring It Up with useActionState

React 19 ships useActionState (it replaced the old useFormState from the React DOM canary releases). Import it from "react", not "react-dom". It gives you three things: the current action state, a form action dispatcher, and a pending boolean. That pending boolean is how you show loading states without managing your own useState.

// components/signup-form-with-action.tsx
"use client";

import { useActionState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, type SignupInput } from "@/lib/schemas/signup";
import { signupAction } from "@/app/actions/signup";

export function SignupFormWithAction() {
  const [state, formAction, isPending] = useActionState(signupAction, {});

  const {
    register,
    formState: { errors: clientErrors },
  } = useForm<SignupInput>({
    resolver: zodResolver(signupSchema),
    mode: "onBlur",
  });

  // RHF's onBlur validation still fires per-field, but handleSubmit
  // isn't wired here. The form submits directly to the Server Action.
  // Server-side Zod re-validation catches anything the client missed.
  const emailError =
    clientErrors.email?.message || state.errors?.email?.[0];

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" {...register("name")} name="name" />
        {clientErrors.name && (
          <p role="alert">{clientErrors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register("email")} name="email" />
        {emailError && <p role="alert">{emailError}</p>}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "Checking..." : "Sign up"}
      </button>

      {state.errors?._form && (
        <p role="alert">{state.errors._form[0]}</p>
      )}

      {state.success && <p>Account created. Check your inbox.</p>}
    </form>
  );
}

Notice how isPending drives the button state. No useState, no useEffect, no manual loading flags. The form submits, the button shows “Checking…”, and when the Server Action returns, React updates the state automatically.

The prevState parameter in the Server Action signature is what makes this work. useActionState passes the previous return value back into the action on each submission. First call gets the initial state ({}). Every subsequent call gets whatever the action returned last time.

The UX Trade-Off: Blocking vs. Background

This decision shapes your architecture. Do you block the form until verification completes, or accept the submission and verify later?

Blocking validation catches bad emails before they enter your system. The user can fix a typo immediately. But if your API call is slow or the mail server uses greylisting, your user stares at a spinner. For signup forms, a 3-second ceiling with fail-open behavior is the right call.

Background verification lets the user through instantly and runs deep checks async. Better UX, but you need a plan for what happens when verification fails 30 seconds later. Send a confirmation email? Flag the account? Both?

Most production systems use a hybrid. Syntax + MX checks in the Server Action (under 300ms). Full SMTP verification in a background job. If the deep check fails, trigger a confirmation flow.

Layer 3: Async Webhook Pattern for Batch

Signup forms handle one email at a time. What about importing a CSV of 10,000 leads? You can’t run 10,000 synchronous Server Actions.

You need webhook patterns. Submit the batch to the validation API, get results delivered to a webhook endpoint.

// app/api/validate-batch/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const { emails } = await request.json();

  // Submit batch to validation API
  const response = await fetch("https://api.truemail.io/v1/batch", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TRUEMAIL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      emails,
      webhook_url: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/validation`,
    }),
  });

  const { batch_id } = await response.json();
  return NextResponse.json({ batch_id, status: "processing" });
}
// app/api/webhooks/validation/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const payload = await request.json();

  // Verify webhook signature
  const signature = request.headers.get("x-truemail-signature");
  if (!verifyWebhookSignature(signature, payload)) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  // Process results: update database, flag invalid contacts
  for (const result of payload.results) {
    await db.contact.update({
      where: { email: result.email },
      data: {
        emailStatus: result.status,
        emailDisposable: result.disposable,
        emailCatchAll: result.catch_all,
      },
    });
  }

  return NextResponse.json({ received: true });
}

The batch endpoint fires off the job. The webhook endpoint receives results minutes later. No Server Actions involved here. This is standard Route Handler territory.

For catch-all domains, the batch pattern is especially important. These domains accept mail for any address, real or fake. Detecting them requires additional signals beyond basic SMTP, and that takes time you don’t have in a form submission.

Error Handling in Server Actions

Server Actions can fail in ways client-side code can’t. Network errors, API timeouts, database constraints. You need to handle all of them without crashing the page.

// app/actions/signup.ts (error handling section)
export async function signupAction(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  try {
    // ... validation and API calls

    await db.user.create({
      data: { name: parsed.data.name, email: parsed.data.email },
    });

    return { success: true };
  } catch (error) {
    if (error instanceof Error && error.name === "TimeoutError") {
      // AbortSignal.timeout throws TimeoutError, not AbortError
      return { success: true };
    }

    // Database unique constraint violation
    if (isUniqueConstraintError(error)) {
      return { errors: { email: ["That email is already registered"] } };
    }

    // Generic fallback
    return { errors: { _form: ["Something went wrong. Try again."] } };
  }
}

Don’t throw from Server Actions unless you want the nearest Error Boundary to catch it. For validation errors and expected failures, return them as state. Reserve thrown errors for truly unexpected situations.

What About Pages Router?

Don’t use it for new projects. Pages Router still works, but useActionState, Server Actions, and the action prop on forms are all App Router + React 19 features. If you’re on Pages Router, you’re writing API routes and fetch calls manually. That’s the Next.js 12 pattern.

The App Router gives you colocation (action next to component), automatic serialization (FormData handled by the framework), progressive enhancement (forms work without JavaScript), and type safety (end-to-end with TypeScript and Zod).

Migrating? Move your /pages/api/validate.ts route handler to app/actions/validate.ts with a "use server" directive. Swap your fetch("/api/validate") calls for direct Server Action imports. The mental model shifts from “client calls API endpoint” to “client calls server function.” Less boilerplate, fewer moving parts, and the types flow end-to-end without you writing a single API schema.

Caching Validation Results

You don’t want to hit the validation API every time the same user resubmits the form after fixing their name field. Cache results in Redis if you’ve got it, or a module-scoped Map for single-server deployments. A Map won’t survive deploys and won’t share across serverless invocations, but it’s fine for a VPS or long-running Node process.

// lib/validation-cache.ts
const cache = new Map<string, { status: string; timestamp: number }>();
const TTL = 1000 * 60 * 60; // 1 hour

export function getCachedValidation(email: string) {
  const entry = cache.get(email.toLowerCase());
  if (!entry) return null;
  if (Date.now() - entry.timestamp > TTL) {
    cache.delete(email.toLowerCase());
    return null;
  }
  return entry.status;
}

export function setCachedValidation(email: string, status: string) {
  cache.set(email.toLowerCase(), { status, timestamp: Date.now() });
}

Then in your Server Action, check the cache before calling the API:

const cached = getCachedValidation(parsed.data.email);
if (cached === "undeliverable") {
  return { errors: { email: ["That email doesn't appear to be deliverable"] } };
}

if (!cached) {
  // Only call API if we don't have a cached result
  const emailCheck = await fetch("https://api.truemail.io/v1/verify", {
    // ... same as before
  });
  // ... handle response and cache the result
}

Set TTL to 1 hour for undeliverable results and 24 hours for deliverable. An email that was valid this morning is still valid this afternoon. An email that bounced might get fixed, so give it a shorter window.

This cuts your API costs significantly on forms where users fix one field and resubmit. No reason to re-validate an email you already checked 30 seconds ago.

The Full Stack

The whole picture in one sequence:

  1. User types email. React Hook Form + Zod validates syntax on blur. Instant red border on user@. Cost: 0ms network.
  2. User submits form. useActionState dispatches to the Server Action. Button shows “Checking…” via isPending.
  3. Server Action runs. Re-validates with Zod (never trust the client), then calls the validation API with syntax + MX checks. Returns errors or success in under 400ms.
  4. Background job fires. Full SMTP verification runs async. If it fails, trigger a confirmation email.
  5. Batch imports use webhooks. Submit the list, receive results via webhook, update records in bulk.

Each layer catches what the previous one missed. Zod catches syntax. The API catches dead domains and disposable addresses. SMTP catches non-existent mailboxes. Webhooks handle scale.

That’s the modern Next.js 15 approach. Shared Zod schemas, Server Actions for verification, useActionState for loading states, and webhooks for batch. No deprecated APIs, no Pages Router workarounds, no blocking spinners.