Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / May 7, 2026

Validate an email address in Next.js

7 minutes read

Qualisend team
A code editor window titled route.ts showing three narrowing Next.js validation layers — a zod email schema, an MX lookup, and a verify API call — ending in a green deliverable badge.

To validate an email address in Next.js you have to answer three questions, and the App Router gives you a clean place to answer each one. Is the address shaped correctly? Can its domain receive mail? Does the mailbox actually exist? The first is a zod schema you share between a Client Component and the server; the second is a DNS lookup; the third is a network problem you hand to a verification API. The trap is stopping at the first question — a z.string().email() pass in the browser is a UX nicety, not validation, and it never reaches your database intact, because anyone can POST straight past it.

The short answer#

Validate with a shared zod schema for syntax on both the client and the server, node:dns/promises for the MX lookup inside a Route Handler, and a verification API for the SMTP mailbox check — cheapest first, short-circuiting as soon as one layer is decisive. Keep every check that matters on the server: the client copy is for instant feedback, and the API key that authenticates the mailbox probe must never ship to the browser. Don't try to open SMTP connections from a Next.js function to probe mailboxes yourself — outbound port 25 is blocked on most hosts (and unavailable on the Edge runtime entirely), and the answer depends on sending-IP reputation and greylisting you don't want to reimplement.

Layer 1: format validation with a shared zod schema#

Next.js has no email primitive of its own, but the idiomatic format check is a zod schema, and the reason zod fits so well here is that the same schema runs in the browser and on the server. Define it once:

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

export const signupSchema = z.object({
  email: z.string().email("Enter a valid email address."),
});

In a Client Component, parse against it on submit so the user gets an instant error without a round-trip. safeParse returns a discriminated result you can read without a try/catch:

"use client";

import { useState } from "react";
import { signupSchema } from "@/lib/email";

export function SignupForm() {
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const email = String(new FormData(e.currentTarget).get("email"));

    // Instant feedback only — this is not a security boundary.
    const parsed = signupSchema.safeParse({ email });
    if (!parsed.success) {
      setError(parsed.error.issues[0].message);
      return;
    }

    const res = await fetch("/api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email }),
    });
    setError(res.ok ? null : (await res.json()).error);
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" autoComplete="email" required />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Sign up</button>
    </form>
  );
}

The client check is worth having — it saves a request on obvious typos — but it is the opposite of trustworthy. A user with the network tab open can POST to /api/signup directly and skip it entirely, so the server has to run the exact same schema again before it believes anything.

Layer 2: can the domain receive mail?#

This is the first check the browser can't do and zod won't do: a domain with no MX records can't accept mail for anyone, so one DNS lookup eliminates dead domains, misspelled company names, and invented TLDs. A Next.js Route Handler runs on Node by default, so you have node:dns/promises with zero dependencies:

// lib/email.ts (continued)
import { resolveMx } from "node:dns/promises";

export async function hasMailRoute(domain: string): Promise<boolean> {
  try {
    const mx = await resolveMx(domain);
    return mx.length > 0;
  } catch {
    // ENOTFOUND / ENODATA → the domain doesn't exist or publishes no MX.
    return false;
  }
}

A pass here means "the domain accepts mail," not "this mailbox exists" — but a failure is decisive and free, which is exactly what you want from a cheap layer.

Layer 3: does the mailbox actually exist?#

Layers 1 and 2 can only rule an address out. A domain can publish perfect MX records and still have no mailbox at the address you're holding — noreply-9f2x@gmail.com is valid syntax on a live mail route, and it's still a mailbox that was never created. Confirming a specific mailbox means the SMTP delivery conversation: connect to the mail host, issue RCPT TO, read the reply, and disconnect before sending anything.

You could script that from a Node runtime, but you shouldn't run it from a Next.js function. Most cloud hosts block outbound port 25, the answer depends on the reputation of the IP you connect from, and receiving servers greylist and rate-limit unfamiliar senders — so a probe that works locally quietly fails, or gets you blocklisted, in production. How email verification works walks the full pipeline, catch-all domains and all. This is the layer worth delegating.

Qualisend's verify endpoint runs the whole pipeline from reputation-managed infrastructure and returns a verdict. Call it server-side with fetch, reading the key from an environment variable so it never leaves the machine:

// lib/email.ts (continued)
export async function verifyMailbox(email: string): Promise<string> {
  const res = await fetch("https://api.qualisend.com/v1/verify", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email }),
  });

  // Don't block a real signup on our outage — treat a failure as "unknown".
  if (!res.ok) return "unknown";

  const { result } = await res.json();
  return result.status; // "deliverable" | "risky" | "undeliverable" | "unknown"
}

The response is a { "result": { ... } } envelope. You read result.status alongside a score, a reason, and sub_flags for traits like role or disposable addresses:

{
  "result": {
    "status": "deliverable",
    "score": 95,
    "reason": null,
    "sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
  }
}

Check the API reference for the exact field shapes; the helper above only needs status to reject an address that won't deliver. Note the environment variable is plain QUALISEND_API_KEY, not prefixed with NEXT_PUBLIC_ — that prefix is what inlines a value into the client bundle, and a verification key in the browser is a key anyone can read and spend.

Composing the layers to validate an email address in Next.js#

Now stitch the three helpers into one route.ts, cheapest first, so the API credit is only spent on an address that already passed syntax and the MX check:

// app/api/signup/route.ts
import { NextResponse } from "next/server";
import { signupSchema, hasMailRoute, verifyMailbox } from "@/lib/email";

export async function POST(request: Request) {
  // Layer 1 — re-validate on the server; the client check is not a boundary.
  const parsed = signupSchema.safeParse(await request.json());
  if (!parsed.success) {
    return NextResponse.json({ error: "Enter a valid email address." }, { status: 400 });
  }

  const { email } = parsed.data;
  const domain = email.slice(email.lastIndexOf("@") + 1);

  // Layer 2 — cheap MX gate, rules out dead domains before any API call.
  if (!(await hasMailRoute(domain))) {
    return NextResponse.json({ error: "That domain can't receive email." }, { status: 400 });
  }

  // Layer 3 — the SMTP mailbox probe, delegated to Qualisend.
  if ((await verifyMailbox(email)) === "undeliverable") {
    return NextResponse.json(
      { error: "We couldn't confirm a mailbox at this address." },
      { status: 400 },
    );
  }

  // Every layer passed (or the probe was inconclusive) — create the account.
  // await createUser(email);
  return NextResponse.json({ ok: true });
}

That ordering is the whole trick: the schema short-circuits junk, the local DNS check rules out dead domains for nothing, and the API is hit only for addresses that cleared both. The handler hard-fails only undeliverable and lets risky and unknown through, so a borderline address or a transient API failure never turns a real customer away — you can branch on score or a sub_flags entry downstream instead of blocking registration.

If you'd rather post the form without writing client fetch code, the identical body works in a Server Action. Return { error } instead of NextResponse and consume it with useActionState:

"use server";

import { signupSchema, hasMailRoute, verifyMailbox } from "@/lib/email";

export async function signup(_prev: unknown, formData: FormData) {
  const parsed = signupSchema.safeParse({ email: formData.get("email") });
  if (!parsed.success) return { error: "Enter a valid email address." };

  const { email } = parsed.data;
  const domain = email.slice(email.lastIndexOf("@") + 1);
  if (!(await hasMailRoute(domain))) return { error: "That domain can't receive email." };

  if ((await verifyMailbox(email)) === "undeliverable") {
    return { error: "We couldn't confirm a mailbox at this address." };
  }
  return { ok: true };
}

This is the same three-layer shape you'll find in the Node.js guide — the layering, not the framework, is what makes validation reliable. Run this fast pipeline synchronously at signup and reserve deeper batch checks for list cleaning; the serverless signup guide shows the real-time pattern end to end, and the API comparison lines up the services that can back layer three.

Frequently asked questions#

Is z.string().email() enough to validate an email address in Next.js?#

For syntax, yes — z.string().email() is the right layer-one check and far better than a hand-rolled regex. But it validates shape: it never resolves DNS or contacts a mail server, so a pass means "looks like an email," not "will deliver." And a browser-only check is trivially bypassed by posting to your route directly. Re-run the schema on the server, then add an MX lookup and an SMTP mailbox check before you trust the address.

Where do I put the Qualisend API key in a Next.js app?#

In a server-only environment variable named QUALISEND_API_KEY — never one prefixed with NEXT_PUBLIC_. The NEXT_PUBLIC_ prefix inlines a value into the client bundle, so a verification key with that prefix is readable by anyone who opens your site. Read process.env.QUALISEND_API_KEY inside a Route Handler or Server Action, where it stays on the server, and let the browser call your own endpoint instead of Qualisend directly.

Should I validate an email on the client or the server in Next.js?#

Both, but only the server counts. Run the shared zod schema in the Client Component for instant feedback on typos, and run the exact same schema again in the Route Handler or Server Action because the client check can be skipped entirely. The DNS and SMTP layers are server-only by nature — they need Node APIs and a secret key — so the browser never sees them.

Can I run the DNS/MX check on the Edge runtime?#

No. node:dns/promises requires the Node.js runtime, which Route Handlers use by default; a route with export const runtime = "edge" has no dns module. If you need Edge, drop the local MX gate and let Qualisend resolve DNS as part of the verify call — its pipeline already does the MX lookup and the SMTP probe, so the single API call covers layers two and three.


Ready to add the mailbox layer? Drop a syntactically perfect address into the free email checker to watch a zod-approved string come back undeliverable, then wire the same verdict into your Route Handler with the copy-paste examples in the API reference.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started