← Blog

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 a NEXT_PUBLIC_ variable, and the browser only ever calls your own route.
  • Read checks.not_performed before 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.

Frequently asked questions

How do I validate an email address in Next.js?

A common pattern is a fast syntax check on the client for instant feedback, then the real validation in a Next.js API route or Route Handler so your logic and any API keys stay server-side. From there you can call a validation API such as Boundstone with one request per address, which checks syntax, resolves the domain's MX records, and flags disposable, role, and free-provider addresses. Every response also lists exactly which checks ran and which did not, so your form logic never assumes more than the data actually proves.

Does a 'valid' email address mean it really exists and can receive mail?

No. A valid result from a validation step like Boundstone's means the address is correctly formatted and its domain has mail servers configured (valid_syntax plus mx_found) — it does not prove a specific mailbox exists or will accept your message. Boundstone deliberately reports SMTP mailbox verification and catch-all detection as not performed rather than guessing, because those checks are often blocked by mail servers and give false confidence. Treat validation as a way to reject clearly-bad addresses, not as a guarantee of deliverability.

How do I block disposable or role-based email addresses at signup in Next.js?

Call a validation API from a server-side API route when the form is submitted, and reject based on the signals it returns. Boundstone flags disposable domains, role accounts such as info@ or admin@, and free-provider addresses, alongside failed syntax or a missing MX record, so you can set your own policy — for example block disposable addresses but allow free providers. The free tier includes 250 checks a month with no card required and credits that never expire, so you can wire this into a signup flow before deciding on a paid plan.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist