How to validate an email address in Next.js
Do the instant format check in the browser, but keep MX, disposable, and role checks — and your bs_live_ key — behind a server route.
Contents
You want to validate an email address in Next.js, and you have two jobs that look like one. The first is instant feedback in the browser, so a user who fat-fingers jane@gmail sees it before they submit. The second is a real answer to "does this address stand a chance of receiving mail" — which needs a DNS lookup and a disposable-domain list, neither of which belongs in client code. This post keeps the two apart, because the tool for each is different, and because getting the split wrong leaks your API key into the browser bundle. If you want the framework-agnostic version, the React walkthrough covers the same client/server split.
Client-side syntax: fine for UX, not a verdict
The platform already gives you most of the browser half for free. type="email" gets you native validation and the right mobile keyboard; a one-line regex gets you a live "this looks like an address" signal you can wire to a disabled submit button. That is genuinely all the client needs — as long as you are honest about what it is. It checks shape, nothing else. It cannot tell you the domain accepts mail, and a determined user can bypass it in a second. Treat it as a UX affordance, not a gate.
// app/ui/email-field.tsx
"use client";
import { useState } from "react";
export default function EmailField() {
const [email, setEmail] = useState("");
const looksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
return (
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={email.length > 0 && !looksValid}
/>
);
}
Why the real checks run on the server
MX resolution, disposable-domain matching, and role-account detection need two things a browser should never have: a call to a third-party API, and the credential to make it. Your Boundstone key is a bs_live_ server-side secret. Anything imported into a Client Component ships to the browser, and any environment variable you prefix with NEXT_PUBLIC_ is inlined into the client bundle at build time. So the rule is short: the key lives in process.env.BOUNDSTONE_API_KEY, it is never NEXT_PUBLIC_, and it is only ever read in server code.
# .env.local — not committed, and NOT prefixed NEXT_PUBLIC_
BOUNDSTONE_API_KEY=bs_live_YOUR_KEY
The Route Handler pattern
Create app/api/verify-email/route.ts. It takes an email, calls Boundstone with the key from process.env, and returns the JSON. The key never leaves the server; the browser only ever talks to your own origin.
// app/api/verify-email/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { email } = await req.json();
const res = await fetch("https://api.boundstone.io/v1/verify/email", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BOUNDSTONE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
Call your own route from the client
Back in the Client Component, fetch /api/verify-email — your route, same origin, no key in sight. Run this on blur or submit, not on every keystroke; the regex already handles the keystroke-level feedback.
async function check(email: string) {
const res = await fetch("/api/verify-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const data = await res.json();
// data.mx_found, data.disposable, data.role_account, data.checks …
return data;
}
A Server Action does the same job
If you would rather not stand up a route, a Server Action keeps the key server-side too. Mark the module "use server", read process.env inside it, and call it from a Client Component or a form action. The credential stays on the server either way — pick whichever fits your data flow.
// app/actions.ts
"use server";
export async function verifyEmail(email: string) {
const res = await fetch("https://api.boundstone.io/v1/verify/email", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BOUNDSTONE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
return res.json();
}
For the server-side call itself — retries, error handling, batching — the Node.js walkthrough goes deeper than we do here.
What a "valid" actually means
Every response tells you what it checked and what it did not. For a well-formed address the checked list is exactly:
{
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
}
Alongside checks, the response gives you valid_syntax, domain, mx_found, disposable, role_account, and free_provider. Note that free_provider is a returned flag, not a member of checks.performed — it tells you gmail-vs-corporate, not pass-or-fail.
The two entries in not_performed are the honest part. Boundstone does not open an SMTP conversation with the mailbox, and it does not probe for a catch-all domain — those are the checks that get you rate-limited, blocked, or quietly lied to by a server that accepts every address. So a "valid" here means: the syntax parses, the domain publishes MX records, the domain is not on the disposable list, and the address is not an obvious role account. It does not mean a human reads that inbox. Knowing precisely where the checking stops is the reason to trust the part that ran. If you just want to eyeball one address, the free email validator runs these same checks with no key and no signup.
The short version
type="email"plus a regex gives instant, client-side UX. That is all it is — shape, not a verdict.- MX, disposable, and role checks run server-side, in a Route Handler or a Server Action. Never in a Client Component.
- The key lives in
process.env.BOUNDSTONE_API_KEY, never in aNEXT_PUBLIC_variable, and the browser only ever calls your own route. - Read
checks.not_performedbefore you act on a "valid": no SMTP mailbox check, no catch-all detection. A "valid" means routable, not deliverable. - Free tier is 250 credits a month, no card, and credits never expire — enough to wire this up end to end.