How to Block Disposable Email Signups in Next.js
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 bereviewfor 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.