Next.jsでメールアドレスを検証するには、3つの問いに答える必要があります。App Routerには、それぞれに対応する場所が用意されています。アドレスの形式は正しいか。そのドメインはメールを受信できるか。そのメールボックスは実際に存在するか。1つ目はクライアントコンポーネントとサーバーで共有するzodスキーマ、2つ目はDNSルックアップ、3つ目はネットワーク越しの確認が必要なので検証APIに任せます。落とし穴は1つ目で止めてしまうことです。ブラウザーでz.string().email()に通っても、それは操作性を高める工夫にすぎず、検証とは言えません。誰でもそれを飛ばして直接POSTできるため、その結果がデータベースまで保たれることはないからです。
結論#
形式の検証にはクライアントとサーバーで共有するzodスキーマ、MXの確認にはRoute Handler内のnode:dns/promises、SMTPによるメールボックスの確認には検証APIを使います。コストの低い順に実行し、結果が確定した時点で処理を打ち切ります。重要なチェックはすべてサーバー側に置いてください。クライアント側のコピーは即時のフィードバック用です。メールボックスの確認を認証するAPIキーは、ブラウザーに渡してはいけません。メールボックスを確認するためのSMTP接続を、Next.jsの関数から自分で開こうとしないでください。多くのホスティング環境ではアウトバウンドのポート25がブロックされており、Edgeランタイムではそもそも使えません。しかも結果は送信IPのレピュテーションやグレーリスティングに左右され、これを自前で再実装するのは避けたいところです。
レイヤー1:共有zodスキーマによる形式の検証#
Next.jsにはメールアドレス用のプリミティブがありません。形式チェックの定番はzodスキーマで、ブラウザーとサーバーでまったく同じスキーマを実行できる点で、特に適しています。まず1か所で定義します。
// lib/email.ts
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Enter a valid email address."),
});
クライアントコンポーネントでは、送信時にこのスキーマで解析します。これでサーバーとの往復なしに、すぐエラーを表示できます。safeParseは判別可能な結果を返すので、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>
);
}
明らかな入力ミスなら1回のリクエストを省けるので、クライアント側のチェックには価値があります。ただし、信頼できるものではありません。ネットワークタブを開いたユーザーなら/api/signupに直接POSTして、このチェックを丸ごと飛ばせます。そのためサーバーでも、何かを信用する前にまったく同じスキーマをもう一度実行する必要があります。
レイヤー2:ドメインはメールを受信できるか#
ここからは、ブラウザーにもzodにもできないチェックです。MXレコードのないドメインは誰宛てのメールも受け付けられないため、DNSルックアップを1回行うだけで、存在しないドメインや会社名のつづり間違い、架空のTLDを除外できます。Next.jsのRoute Handlerは標準でNode上で動くので、依存パッケージなしでnode:dns/promisesを使えます。
// 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;
}
}
ここに通っても分かるのは「そのドメインはメールを受け付ける」ことだけで、「このメールボックスが存在する」ことではありません。ただし失敗した場合は確定的で、しかも無料です。コストの低いレイヤーに求められるのは、まさにその性質です。
レイヤー3:メールボックスは実際に存在するか#
レイヤー1と2でできるのは、アドレスを除外することだけです。ドメインに完璧なMXレコードがあっても、手元のアドレスにメールボックスがないことはあります。たとえばnoreply-9f2x@gmail.comは、構文として有効で、メールの経路も実在しますが、作成されたことのないメールボックスです。特定のメールボックスを確認するには、SMTPの配信時のやり取りが必要です。メールホストに接続してRCPT TOを発行し、応答を読み取り、何も送らずに切断します。
これはNodeランタイムからスクリプトで実行することもできますが、Next.jsの関数から実行するのはおすすめしません。ほとんどのクラウドホストはアウトバウンドのポート25をブロックしています。結果は接続元のIPのレピュテーションに左右され、受信サーバーは見慣れない送信元をグレーリスティングしたりレート制限したりします。そのため、ローカルでは動くプローブが、本番では黙って失敗するか、ブラックリストに載る原因になります。キャッチオールドメインを含むパイプライン全体は、メールアドレス検証の仕組みで解説しています。このレイヤーこそ、外部に任せる価値があります。
Qualisendの検証エンドポイントは、レピュテーションを管理したインフラでパイプライン全体を実行し、判定を返します。サーバー側からfetchで呼び出し、キーは環境変数から読み取ることで、マシンの外に出ないようにします。
// lib/email.ts (continued)
export async function verifyMailbox(email: string): Promise<string> {
const res = await fetch("https://app.qualisend.com/api/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(4000), // a slow call must not stall signup
}).catch(() => null); // timeout or network error: fail open too
// 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"
}
レスポンスでは、判定がjob_idと並んでresultエンベロープに入っています。result.statusに加えて、score、reason、そして役割アドレスや使い捨てアドレスなどの特徴を示すsub_flagsを読み取れます。
{
"job_id": "6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f",
"result": {
"status": "deliverable",
"score": 95,
"reason": "accepted_email",
"smtp": "cached",
"sub_flags": { "role": false, "disposable": false, "free_mail": false, "catch_all": false }
}
}
このresultは即時の判定です。result.smtpが"cached"または"skipped"なら、すでに最終結果です。"pending"の間はSMTPのメールボックスプローブが実行中で、statusはunknown、reasonはnullのままです。最終的な判定はGET https://app.qualisend.com/api/v1/jobs/{job_id}で取得できます。サインアップ時には即時の判定に基づいて処理してください。最終的な判定が必要な場合は、バックグラウンドタスクからそのジョブをポーリングします。リクエストの処理中にポーリングしたり、/verifyをもう一度呼び出したりしてはいけません。
正確なフィールドの形はAPIリファレンスで確認してください。上のヘルパーが使うのは、配信できないアドレスを拒否するためのstatusだけです。環境変数の名前はQUALISEND_API_KEYそのままで、NEXT_PUBLIC_を付けないでください。この接頭辞が付くと値がクライアントのバンドルに埋め込まれ、ブラウザー上の検証キーは誰でも読み取れて、消費もできるキーになってしまいます。
3つのレイヤーを組み合わせてNext.jsでメールアドレスを検証する#
3つのヘルパーを1つのroute.tsにまとめます。コストの低い順に実行するので、APIのクレジットを消費するのは、構文チェックとMXチェックに通ったアドレスだけです。
// 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 });
}
この順序が肝心です。スキーマが明らかな不正入力を弾き、ローカルのDNSチェックが無料で存在しないドメインを除外し、APIを呼び出すのは両方に通ったアドレスだけです。ハンドラーが拒否するのはundeliverableだけで、riskyとunknownは通します。そのため、判断の難しいアドレスやAPIの一時的な障害のせいで、本物の顧客を断ることはありません。登録をブロックする代わりに、後続の処理でscoreやsub_flagsの値によって分岐させることもできます。
クライアントのfetchコードを書かずにフォームをPOSTしたい場合は、同じ処理をServer Actionでも使えます。NextResponseの代わりに{ error }を返し、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 };
}
この3層構成は、Node.jsガイドにあるものと同じです。検証を確実にするのはフレームワークではなく、レイヤー構造そのものです。この高速なパイプラインはサインアップ時に同期的に実行し、より深いバッチ検証はリストのクリーニング用に取っておいてください。リアルタイムのパターンはサーバーレスのサインアップガイドで最初から最後まで確認できます。レイヤー3を担えるサービスはAPIの比較にまとめています。
よくある質問
01Next.jsでメールアドレスを検証するにはz.string().email()だけで十分ですか?
構文チェックとしては十分です。z.string().email()はレイヤー1のチェックとして適切で、自作の正規表現よりはるかに優れています。ただし検証するのは形式だけで、DNSの解決もメールサーバーへの接続も行いません。通過しても「メールアドレスの形をしている」ことが分かるだけで、「配信できる」ことは保証されません。ブラウザー側だけのチェックは、ルートに直接POSTされれば簡単に回避されます。サーバーで同じスキーマを再実行し、さらにMXルックアップとSMTPによるメールボックスの確認を加えてから、アドレスを信頼してください。
02Next.jsアプリのどこにQualisendのAPIキーを置けばよいですか?
QUALISEND_API_KEYという名前のサーバー専用の環境変数に置き、NEXT_PUBLIC_で始まる名前は使わないでください。NEXT_PUBLIC_接頭辞が付いた値はクライアントのバンドルに埋め込まれるため、その接頭辞の付いた検証キーは、サイトを開いた人なら誰でも読み取れます。process.env.QUALISEND_API_KEYはサーバーに留まるRoute HandlerかServer Actionの中で読み取り、ブラウザーからはQualisendを直接呼び出さず、自社のエンドポイントを呼び出すようにします。
03Next.jsでは、メールアドレスの検証をクライアントとサーバーのどちらで行うべきですか?
両方で行いますが、効力を持つのはサーバー側だけです。クライアントコンポーネントでは共有のzodスキーマを実行して、入力ミスへの即時フィードバックを返します。Route HandlerやServer Actionでも、まったく同じスキーマをもう一度実行してください。クライアント側のチェックは丸ごと飛ばされる可能性があるからです。DNSとSMTPのレイヤーは、性質上サーバー専用です。Node APIとシークレットキーが必要なので、ブラウザーには一切見えません。
04EdgeランタイムでDNS/MXチェックを実行できますか?
できません。node:dns/promisesにはNode.jsランタイムが必要です。Route Handlerは標準でNode.jsランタイムを使いますが、export const runtime = "edge"を指定したルートにはdnsモジュールがありません。Edgeが必要な場合は、ローカルのMXチェックを省き、検証の呼び出しの中でQualisendにDNSを解決させてください。パイプラインはすでにMXルックアップとSMTPプローブを行うため、API呼び出し1回でレイヤー2と3を両方カバーできます。
メールボックスのレイヤーを追加してみませんか。構文上は完璧なアドレスを無料メールチェッカーに入力すると、zodを通過した文字列がundeliverableと判定される様子を確認できます。同じ判定をRoute Handlerに組み込むには、APIリファレンスのコピー&ペースト用サンプルをご利用ください。



