emailverifier.dev
Posts

How to Block Disposable Email Signups in Next.js

| 5 min read | Usama Ejaz
Next.js signup check cover with an email inside a server box and a block symbol.

This tutorial builds a working Next.js signup-email check from a new App Router project. By the end, the browser will submit an address to your own Route Handler, the handler will make one emailverifier.dev request with a server-only key, and the page will show whether the address can continue.

Create the Next.js project

You need a current Node.js release and npm. Create the app with the current create-next-app command:

npx create-next-app@latest next-email-check --yes
cd next-email-check

The generated project uses the App Router. The relevant files will be:

next-email-check/
├─ app/
│  ├─ api/signup/check-email/route.ts
│  └─ page.tsx
└─ .env.local

The Next.js installation guide documents the scaffolding command and development server.

Create the project key

Create an emailverifier.dev account, create a project, and copy the API key when it is shown. Add it to .env.local:

EMAILVERIFIER_API_KEY=replace_with_your_project_key

Do not add NEXT_PUBLIC_. Next.js exposes variables with that prefix to browser code. The Route Handler can read the unprefixed value through process.env.

Build the Route Handler

Create app/api/signup/check-email/route.ts:

import { NextResponse } from 'next/server'

type VerificationResult = {
  email: string
  status: 'deliverable' | 'risky' | 'undeliverable' | 'unknown'
  action: 'allow' | 'block' | 'review'
  flagged: boolean
  signals: string[]
  suggestion: string | null
}

export async function POST(request: Request) {
  const body: unknown = await request.json().catch(() => null)
  const email =
    body &&
    typeof body === 'object' &&
    'email' in body &&
    typeof body.email === 'string'
      ? body.email.trim()
      : ''

  if (!email) {
    return NextResponse.json(
      { error: 'Email is required.' },
      { status: 400 }
    )
  }

  const apiKey = process.env.EMAILVERIFIER_API_KEY
  if (!apiKey) {
    return NextResponse.json(
      { error: 'Email verification is not configured.' },
      { status: 500 }
    )
  }

  let response: Response
  try {
    response = await fetch(
      'https://emailverifier.dev/api/v1/verify',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': apiKey
        },
        body: JSON.stringify({ email }),
        signal: AbortSignal.timeout(5000),
        cache: 'no-store'
      }
    )
  } catch {
    return NextResponse.json(
      { error: 'Email verification is temporarily unavailable.' },
      { status: 503 }
    )
  }

  if (!response.ok) {
    return NextResponse.json(
      { error: 'Email verification is temporarily unavailable.' },
      { status: 503 }
    )
  }

  const result = (await response.json()) as VerificationResult
  const accepted = result.action !== 'block'

  return NextResponse.json(
    {
      accepted,
      needsReview: result.action === 'review',
      result
    },
    { status: accepted ? 200 : 422 }
  )
}

This is the only place that calls emailverifier.dev. The browser receives the result, never the project key. The REST API reference defines the response and authenticated error fields.

Add the working signup form

Replace app/page.tsx with:

'use client'

import { FormEvent, useState } from 'react'

type Decision = {
  accepted?: boolean
  needsReview?: boolean
  error?: string
  result?: {
    status: 'deliverable' | 'risky' | 'undeliverable' | 'unknown'
    action: 'allow' | 'block' | 'review'
    signals: string[]
    suggestion: string | null
  }
}

export default function Home() {
  const [email, setEmail] = useState('')
  const [decision, setDecision] = useState<Decision | null>(null)
  const [pending, setPending] = useState(false)

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setPending(true)
    setDecision(null)

    try {
      const response = await fetch('/api/signup/check-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email })
      })

      setDecision((await response.json()) as Decision)
    } catch {
      setDecision({ error: 'Request failed. Try again.' })
    } finally {
      setPending(false)
    }
  }

  return (
    <main>
      <h1>Check a signup email</h1>

      <form onSubmit={submit}>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
          required
        />
        <button disabled={pending}>
          {pending ? 'Checking…' : 'Check email'}
        </button>
      </form>

      {decision?.error && (
        <p role="alert">{decision.error}</p>
      )}

      {decision?.result && (
        <section aria-live="polite">
          <p>Status: {decision.result.status}</p>
          <p>Action: {decision.result.action}</p>
          <p>
            {decision.accepted
              ? decision.needsReview
                ? 'Continue with email confirmation.'
                : 'Email accepted.'
              : 'Ask for another email address.'}
          </p>
          {decision.result.suggestion && (
            <p>Suggested address: {decision.result.suggestion}</p>
          )}
        </section>
      )}
    </main>
  )
}

The page deliberately stops before creating a user. In an existing product, call the account-creation service only when accepted is true. A review result can continue into email confirmation without being described as a failed mailbox.

Run the tutorial

npm run dev

Open http://localhost:3000 and submit an address. You can also exercise the server route directly:

curl http://localhost:3000/api/signup/check-email \
  -H "Content-Type: application/json" \
  -d "{\"email\":\"person@example.com\"}"

A completed integration returns 200 for allow or review, 422 for block, 400 for missing input, and 503 when the external check cannot finish.

Keep the four outcomes separate

  • deliverable: the available checks passed. The action may still be review for a role address or catch-all domain.
  • risky: a material signal exists. Disposable addresses default to block; a possible typo can provide a suggestion.
  • undeliverable: a confirmed syntax, routing, or recipient failure was found.
  • unknown: recipient evidence was inconclusive. Continue through confirmation or review instead of claiming the mailbox does not exist.

If the product benefit is valuable enough to block temporary providers, use the disposable-email signup policy to define the rule outside the React component.

The tutorial is finished when the form works, the key is absent from browser network payloads, an empty request returns 400, and a normal request returns a structured decision.

Continue reading