# How to validate an email address in Next.js

_2026-10-19 · Boundstone (https://boundstone.io/blog/validate-email-nextjs)_


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](/blog/validate-email-react) 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.

```tsx
// 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.

```bash
# .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.

```ts
// 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.

```tsx
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.

```ts
// 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](/blog/validate-email-nodejs) 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:

```json
{
  "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](/tools/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.
