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 results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.

TypeScript
// 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 disposable = false;
  try {
    const res = await fetch("https://api.zapbounce.com/v1/verify", {
      method: "POST",
      headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(2500),
      cache: "no-store",     // a verdict is not a cacheable document
    });
    if (res.ok) ({ result: verdict, disposable } = await res.json());
  } catch {
    // Fail open. The form must not depend on a third party being quick.
  }

  // disposable is a flag beside the result, so it is tested separately.
  if (verdict === "invalid" || 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 batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

TypeScript
// app/api/lists/[id]/verify/route.ts
import { NextResponse } from "next/server";

export const runtime = "nodejs";   // needs node crypto for the webhook side

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://api.zapbounce.com/v1/batches", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.ZAPBOUNCE_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `list-${id}`,
    },
    body: JSON.stringify({
      name: `list-${id}`,
      emails: emails.map((c) => c.email),
      webhook_url: `${process.env.PUBLIC_URL}/api/webhooks/zapbounce`,
    }),
    cache: "no-store",
  });

  if (!res.ok) {
    const { error } = await res.json();
    return NextResponse.json({ error: error.code }, { status: 502 });
  }

  const batch = await res.json();
  await db.list.update({ where: { id }, data: { batchId: batch.batch_id } });

  return NextResponse.json(batch, { status: 202 });
}

Errors and retries

A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.

TypeScript
// app/api/webhooks/zapbounce/route.ts
import crypto from "node:crypto";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";   // never prerender or cache a webhook

export async function POST(request: Request) {
  // request.text() gives the raw body. Call request.json() first and the bytes
  // that were signed are gone, along with any chance of a matching HMAC.
  const raw = await request.text();
  const header = request.headers.get("zapbounce-signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));

  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
    return new Response("stale signature", { status: 401 });
  }

  const expected = crypto
    .createHmac("sha256", process.env.ZAPBOUNCE_WEBHOOK_SECRET!)
    .update(`${parts.t}.${raw}`)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""))) {
    return new Response("bad signature", { status: 401 });
  }

  const event = JSON.parse(raw);

  const inserted = await db.webhookEvent.createMany({
    data: [{ id: event.id }],
    skipDuplicates: true,       // deliveries repeat; a replay must be a no-op
  });
  if (inserted.count > 0) await queue.enqueue("import", event.data.batch_id);

  return new Response(null, { status: 204 });
}

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. The webhook route needs node crypto for the HMAC, so pin that one to the Node runtime.

Where should bulk verification run?

A route handler that submits the batch and returns 202. 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 that cost nothing.