The correct place to verify an email in Next.js is a Route Handler (or a Server Action) — code that runs on your server, not in the visitor’s browser. Your form collects the address, POSTs it to your own /api/verify endpoint, and that endpoint calls the VerifyAnyEmail API with your secret key attached. The browser never sees the key, and you get a clean deliverable/undeliverable decision back. If you take one thing from this page, take this: the API key stays server-side, always.
Why you must verify on the server
It is tempting to call the verification API straight from a React component with fetch. Don’t. Anything that ships to the browser — including NEXT_PUBLIC_* environment variables and any string baked into client components — is fully visible to anyone who opens dev tools. A leaked key can be drained of credits or abused within minutes. In Next.js the boundary is explicit: Route Handlers under app/api/ and Server Actions run only on the server, so a secret read from process.env there never reaches the client bundle. That is exactly the property we want. For the wider checklist, see how to protect your API keys.
Store the key in .env.local as VAE_API_KEY (note: no NEXT_PUBLIC_ prefix — that prefix is what would expose it):
# .env.local — never commit this file
VAE_API_KEY=vae_your_secret_key_hereThe Route Handler
Create app/api/verify/route.ts. It reads the address from the request body, calls POST /v1/verify with the key from the environment, and returns a small, safe object — never the raw upstream response, and never anything that hints at the key.
// app/api/verify/route.ts — runs ONLY on the server
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { email } = await req.json();
if (typeof email !== 'string' || email.length < 3) {
return NextResponse.json({ error: 'invalid_email' }, { status: 400 });
}
// Abort the upstream call if it takes too long, so a slow probe
// never freezes your signup form.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch('https://api.verifyany.email/v1/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// The key is read on the server and never sent to the browser.
Authorization: 'Bearer ' + process.env.VAE_API_KEY,
'X-VAE-Source': 'nextjs-signup',
},
body: JSON.stringify({ email }),
signal: controller.signal,
});
if (!res.ok) {
// 4xx/5xx from upstream: fail open so a hiccup on our side
// does not block a real customer.
return NextResponse.json({ status: 'unknown', allow: true });
}
const data = await res.json();
const status: string = data.result.status;
// Block only the clearly-dead addresses. Allow everything else.
const allow = status !== 'undeliverable';
return NextResponse.json({
status, // deliverable | undeliverable | risky | unknown
allow,
suggestion: data.result.suggestion, // e.g. "gmail.com" for a typo'd "gmial.com"
});
} catch {
// Timeout or network error → fail open.
return NextResponse.json({ status: 'unknown', allow: true });
} finally {
clearTimeout(timer);
}
}Branch on the status
The API returns one of four values in result.status, and each maps to a clear action:
- undeliverable — the mailbox does not exist or the domain can’t receive mail. Block it. Sending here guarantees a hard bounce.
- deliverable — a real, reachable mailbox. Allow it.
- risky — reachable but lower-confidence (catch-all, role-based like
info@, or disposable). Allow it, but you may want to flag it for review rather than trust it blindly. - unknown — the check couldn’t reach a verdict (greylisting, a timeout, a temporarily unreachable server). Fail open: let the user through. Blocking on
unknownpunishes real people for a transient network condition.
The handler above collapses that into one rule — block undeliverable, allow the rest — which is the right default for a signup form where conversion matters. If you need per-status handling (for example, holding risky signups for manual review), read the full breakdown in verify emails at signup without hurting conversion.
The client form
The client component knows nothing about VerifyAnyEmail. It only talks to your /api/verify route. That is the whole point — the browser can’t leak a key it never receives.
'use client';
import { useState } from 'react';
export function SignupForm() {
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
const res = await fetch('/api/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!data.allow) {
setError('That email address looks undeliverable — please check it.');
setBusy(false);
return;
}
if (data.suggestion) {
// Optional: offer a typo fix instead of hard-blocking.
setError('Did you mean @' + data.suggestion + '?');
setBusy(false);
return;
}
// Passed verification — continue your real signup here.
// await fetch('/api/register', ...)
setBusy(false);
}
return (
<form onSubmit={onSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<button disabled={busy}>{busy ? 'Checking…' : 'Sign up'}</button>
{error && <p role="alert">{error}</p>}
</form>
);
}Test it without spending credits
Every address at @test.verifyany.email returns a deterministic result and costs 0 credits — no real mailbox is probed. Use them to exercise each branch of your handler in a test run:
deliverable@test.verifyany.email→deliverable(allowed)undeliverable@test.verifyany.email→undeliverable(blocked)risky@test.verifyany.email→risky(allowed)unknown@test.verifyany.email→unknown(fail-open, allowed)
POST each of these through your own form and confirm the UI does what you expect before you ever touch a live address. You can also try a one-off check in the browser with the free Email Verifier.
Errors, timeouts and retries
Real-time verification runs a live SMTP probe, so a small share of checks will be slow or inconclusive. Handle that deliberately:
- Timeout. The
AbortControllercaps the upstream call at 8 seconds. On abort we returnunknownand allow the user — a signup form should never hang on a slow mail server. - Fail open on 5xx and
429. If the API returns an error or you’re rate-limited, don’t block the customer. Treat it asunknownand, for background work, retry later with exponential backoff. - Don’t retry a
200. A cleanundeliverableis a real answer, not a failure — retrying wastes credits and won’t change the verdict. - Watch
402. That means the account is out of credits; alert yourself rather than silently failing every signup.
For verifying a whole list rather than one address at a time, use the batch endpoint and its webhook instead of looping — see how to process large verification batches.
Wrapping up
A Next.js Route Handler is the clean seam for email verification: the browser posts to your endpoint, your endpoint attaches the key and calls the API, and you return a tiny safe object. Keep the key in process.env on the server, block only undeliverable, fail open on unknown and timeouts, and test every branch against @test.verifyany.email for free. The same server-side pattern applies in other stacks — see Node.js & Express — and the full request/response reference lives in the API docs.