Email verification in Next.js
Next.js blurs the server and client boundary, which makes the first rule worth stating plainly: the API key lives in a server-only environment variable with no NEXT_PUBLIC_ prefix. Prefix it and Next inlines the value into the client bundle, where anyone can read it and spend your credits.
Server actions are a good fit for a signup check. The call runs on the server, the verdict comes back with the form response, and no key crosses the boundary.
Built-in fetch inside server actions and route handlers, with cache: 'no-store' so Next does not serve a verdict from last week.
Verify one address
You branch on four statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.
// app/actions/signup.ts
"use server";
import { redirect } from "next/navigation";
// No NEXT_PUBLIC_ prefix. This must never reach the browser bundle.
const KEY = process.env.ZAPBOUNCE_KEY!;
export async function signup(prevState: unknown, formData: FormData) {
const email = String(formData.get("email") ?? "");
let verdict = "unknown";
let flags: string[] = [];
try {
const res = await fetch("https://app.zapbounce.com/api/v1/verify", {
method: "POST",
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({ emails: [email] }),
signal: AbortSignal.timeout(2500),
cache: "no-store", // a verdict is not a cacheable document
});
if (res.ok) {
const { results } = await res.json();
({ status: verdict, flags } = results[0]);
}
} catch {
// Fail open. The form must not depend on a third party being quick.
}
// disposable is an entry in the flags array beside the status, so it is tested separately.
if (verdict === "invalid" || flags.includes("disposable")) {
return { error: "That address will not receive mail. Check it for a typo." };
}
await db.user.create({ data: { email, emailVerdict: verdict } });
redirect("/welcome");
}Verify a list
Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
// app/api/lists/[id]/verify/route.ts
import { NextResponse } from "next/server";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const emails = await db.contact.findMany({
where: { listId: id, verdict: null },
select: { email: true },
});
const res = await fetch("https://app.zapbounce.com/api/v1/jobs", {
method: "POST",
headers: {
"X-API-Key": process.env.ZAPBOUNCE_KEY!,
"Content-Type": "application/json",
"Idempotency-Key": `list-${id}`,
},
body: JSON.stringify({
emails: emails.map((c) => c.email), // up to 50,000 per job
source: "api",
}),
cache: "no-store",
});
if (!res.ok) {
const err = await res.json(); // { statusCode, code, message, requestId }
return NextResponse.json({ error: err.code }, { status: 502 });
}
const job = await res.json();
await db.list.update({ where: { id }, data: { jobId: job.id } });
return NextResponse.json({ jobId: job.id }, { status: 202 });
}
// app/api/lists/[id]/status/route.ts
// There is no webhook. The client polls this until the status is terminal.
export const dynamic = "force-dynamic";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const { jobId } = await db.list.findUniqueOrThrow({ where: { id } });
const job = await fetch(`https://app.zapbounce.com/api/v1/jobs/${jobId}`, {
headers: { "X-API-Key": process.env.ZAPBOUNCE_KEY! },
cache: "no-store",
}).then((r) => r.json());
return NextResponse.json({ status: job.status }); // completed | partial | failed | cancelled when done
}Errors and retries
A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT and a 400 for a malformed address will fail the same way on every attempt, so stop.
// lib/zapbounce.ts
export class ZapBounceError extends Error {
constructor(
readonly code: string,
message: string,
readonly requestId: string,
readonly status: number,
readonly windowSeconds: number,
) {
super(message);
}
}
export async function zbFetch<T>(path: string, init: RequestInit = {}, attempts = 4): Promise<T> {
for (let i = 0; ; i++) {
const res = await fetch(`https://app.zapbounce.com/api/v1${path}`, {
...init,
headers: {
"X-API-Key": process.env.ZAPBOUNCE_KEY!,
"Content-Type": "application/json",
...init.headers,
},
signal: AbortSignal.timeout(10_000),
cache: "no-store",
});
if (res.ok) return (await res.json()) as T;
// { statusCode, code, message, requestId }
const body = await res.json();
const err = new ZapBounceError(
body.code, body.message, body.requestId, res.status,
Number(res.headers.get("X-RateLimit-Window-Seconds") ?? 1),
);
// CREDITS_INSUFFICIENT is a 422 and never succeeds on retry.
if ((res.status !== 429 && res.status < 500) || i >= attempts - 1) throw err;
// No Retry-After header exists: a 429 waits on the window length.
const floor = res.status === 429 ? err.windowSeconds * 1000 : 500;
await new Promise((r) => setTimeout(r, 2 ** i * floor * (0.5 + Math.random())));
}
}Next.js: common questions
Can I verify from a client component?
Only through your own server action or route handler. A key in client code is public the moment the page loads.
Does the Edge runtime work?
For the verification call, yes. fetch and AbortSignal.timeout both run there, so nothing in this page needs the Node runtime.
Where should bulk verification run?
A route handler that submits the job and returns 202, with a status route to poll. Serverless functions have execution limits that a long list will exceed.
Run this Next.js code today
100 free checks a month, no card, credits that never expire, and unknown results refunded.