Block disposable emails in Next.js with one middleware file
A user signs up with test92847@mailinator.com, burns through your free trial, and disappears.
They come back tomorrow with test92848@mailinator.com and do it again.
Your support queue fills with phantom accounts. Your analytics show inflated user counts that mean nothing.
Your abuse detection fires too late because the account already consumed resources.
The fix: block disposable emails at the door, before the signup request reaches your database. This guide shows how to do it in Next.js with a single middleware file and zero dependencies beyond a fetch call.
What you'll build
A Next.js middleware that intercepts POST requests to your signup endpoint, extracts the email from the request body, checks it against the botoi disposable email API, and returns a 422 response if the email belongs to a throwaway service. The entire file is under 50 lines.
The middleware
Create middleware.ts at your project root (or src/middleware.ts if you use the src directory):
import { NextRequest, NextResponse } from 'next/server';
const BOTOI_URL = 'https://api.botoi.com/v1/disposable-email/check';
export async function middleware(req: NextRequest) {
// Only intercept POST requests to the signup route
if (req.method !== 'POST') {
return NextResponse.next();
}
let body: { email?: string };
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: 'Invalid request body' },
{ status: 400 }
);
}
const email = body.email?.trim().toLowerCase();
if (!email) {
return NextResponse.next();
}
try {
const res = await fetch(BOTOI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (data.success && data.data.is_disposable) {
return NextResponse.json(
{ error: 'Disposable email addresses are not allowed. Please use a permanent email.' },
{ status: 422 }
);
}
} catch {
// API unreachable; fail open so real users aren't blocked
console.warn('botoi disposable-email check failed, allowing request through');
}
return NextResponse.next();
}
export const config = {
matcher: ['/api/auth/signup', '/api/register'],
}; How it works
Route matching
The config.matcher array tells Next.js which routes trigger this middleware.
Change these paths to match your signup endpoints. The middleware runs on the edge before your route handler executes,
so rejected requests never touch your database or auth provider.
Email extraction
The middleware reads the request body with req.json() and pulls the email field.
If parsing fails or no email exists, the request passes through untouched.
This keeps the middleware invisible to non-signup routes.
The API call
A single POST to https://api.botoi.com/v1/disposable-email/check with the email in the body.
The response includes:
{
"success": true,
"data": {
"email": "throwaway@mailinator.com",
"domain": "mailinator.com",
"is_disposable": true,
"is_free": false,
"provider": "Mailinator"
}
}
The is_disposable flag is the gate. When it's true, the middleware returns a 422 with a clear message.
When it's false, NextResponse.next() lets the request continue to your signup handler.
Fail-open design
The catch block around the fetch call means network failures, timeouts, or API downtime don't break signups.
The middleware logs a warning and lets the request through. Your users never see an error caused by a third-party outage.
Handling edge cases
Timeouts
The botoi API responds in under 50ms for most requests since it uses an in-memory domain list.
If you want a hard timeout, wrap the fetch in AbortSignal.timeout():
const res = await fetch(BOTOI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000), // 3 second timeout
}); Rate limits
The free tier allows 5 requests per minute. If your app processes more signups than that, get an API key and pass it as a Bearer token:
const res = await fetch(BOTOI_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.BOTOI_API_KEY}`,
},
body: JSON.stringify({ email }),
});
Store BOTOI_API_KEY in your .env.local file. Never commit it to version control.
Duplicate checks
If the same email hits your signup endpoint twice in quick succession (double-click, retry logic), you'll make two API calls for the same domain. For most apps this is fine. If it matters, add a short-lived cache (covered below).
Production hardening
Add an in-memory cache
Cache the disposable check result per domain for 5 minutes. This reduces API calls and speeds up repeated checks for the same domain:
const cache = new Map<string, { isDisposable: boolean; expires: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async function isDisposableEmail(email: string): Promise<boolean> {
const domain = email.split('@')[1];
const cached = cache.get(domain);
if (cached && cached.expires > Date.now()) {
return cached.isDisposable;
}
try {
const res = await fetch(BOTOI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
const data = await res.json();
const isDisposable = data.success && data.data.is_disposable;
cache.set(domain, {
isDisposable,
expires: Date.now() + CACHE_TTL,
});
return isDisposable;
} catch {
return false; // fail open
}
} This Map-based cache works in serverless and edge runtimes. For multi-instance deployments, swap it for Redis or Upstash:
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
async function isDisposableEmail(email: string): Promise<boolean> {
const domain = email.split('@')[1];
const cached = await redis.get<boolean>(`disposable:${domain}`);
if (cached !== null) {
return cached;
}
try {
const res = await fetch(BOTOI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
const data = await res.json();
const isDisposable = data.success && data.data.is_disposable;
await redis.set(`disposable:${domain}`, isDisposable, { ex: 300 });
return isDisposable;
} catch {
return false;
}
} Allowlist corporate domains
Some companies use custom domains you never want to block, even if they match a suspicious pattern. Add an allowlist:
const ALLOWED_DOMAINS = new Set([
'yourcompany.com',
'partner-corp.io',
'bigclient.co',
]);
async function isDisposableEmail(email: string): Promise<boolean> {
const domain = email.split('@')[1];
if (ALLOWED_DOMAINS.has(domain)) {
return false;
}
// ... rest of the check logic
} Log blocked attempts
Track which domains get rejected so you can monitor abuse patterns and adjust your strategy:
if (data.success && data.data.is_disposable) {
console.log(
JSON.stringify({
event: 'disposable_email_blocked',
domain: data.data.domain,
provider: data.data.provider,
timestamp: new Date().toISOString(),
})
);
return NextResponse.json(
{ error: 'Disposable email addresses are not allowed.' },
{ status: 422 }
);
} Complete middleware with all hardening
Here's the full file with caching, allowlisting, timeout, and structured logging:
import { NextRequest, NextResponse } from 'next/server';
const BOTOI_URL = 'https://api.botoi.com/v1/disposable-email/check';
const CACHE_TTL = 5 * 60 * 1000;
const ALLOWED_DOMAINS = new Set([
// Add your corporate or partner domains here
]);
const cache = new Map<string, { isDisposable: boolean; expires: number }>();
async function checkDisposable(email: string): Promise<{
isDisposable: boolean;
domain: string;
provider: string | null;
}> {
const domain = email.split('@')[1];
if (ALLOWED_DOMAINS.has(domain)) {
return { isDisposable: false, domain, provider: null };
}
const cached = cache.get(domain);
if (cached && cached.expires > Date.now()) {
return { isDisposable: cached.isDisposable, domain, provider: null };
}
try {
const res = await fetch(BOTOI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
const data = await res.json();
const isDisposable = data.success && data.data.is_disposable;
const provider = data.data?.provider ?? null;
cache.set(domain, { isDisposable, expires: Date.now() + CACHE_TTL });
return { isDisposable, domain, provider };
} catch {
return { isDisposable: false, domain, provider: null };
}
}
export async function middleware(req: NextRequest) {
if (req.method !== 'POST') {
return NextResponse.next();
}
let body: { email?: string };
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: 'Invalid request body' },
{ status: 400 }
);
}
const email = body.email?.trim().toLowerCase();
if (!email || !email.includes('@')) {
return NextResponse.next();
}
const result = await checkDisposable(email);
if (result.isDisposable) {
console.log(
JSON.stringify({
event: 'disposable_email_blocked',
domain: result.domain,
provider: result.provider,
timestamp: new Date().toISOString(),
})
);
return NextResponse.json(
{ error: 'Disposable email addresses are not allowed. Please use a permanent email.' },
{ status: 422 }
);
}
return NextResponse.next();
}
export const config = {
matcher: ['/api/auth/signup', '/api/register'],
}; Testing it locally
Start your Next.js dev server and fire a request with a known disposable email:
curl -X POST http://localhost:3000/api/auth/signup \
-H "Content-Type: application/json" \
-d '{"email": "test@mailinator.com", "password": "hunter2"}' Expected response:
{
"error": "Disposable email addresses are not allowed. Please use a permanent email."
} Try a legitimate email to confirm it passes through:
curl -X POST http://localhost:3000/api/auth/signup \
-H "Content-Type: application/json" \
-d '{"email": "dev@acme-corp.com", "password": "hunter2"}' This request reaches your signup handler as normal.
When to check on the client too
The middleware catches disposable emails on the server. But you can also call the same API from your signup form to show an inline error *before* the user submits. A quick client-side check after the email field loses focus saves the user a round trip and gives faster feedback:
async function validateEmail(email: string): Promise<string | null> {
const res = await fetch('https://api.botoi.com/v1/disposable-email/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (data.success && data.data.is_disposable) {
return 'Please use a permanent email address.';
}
return null; // no error
} The middleware still acts as the authoritative gate. The client-side check is a UX improvement, not a security measure.
Frequently asked questions
- Does this work with the Next.js App Router?
- Yes. Next.js middleware runs before any route handler, regardless of whether you use the App Router or Pages Router. The middleware.ts file sits at the project root (or inside src/ if you use the src directory), and it intercepts requests to the matched paths before they reach your API routes or server actions.
- Do I need an API key for the botoi disposable email check?
- No. The free tier allows 5 requests per minute with no API key. For production apps handling more signups, grab a key from the botoi API docs page to unlock higher rate limits.
- What happens if the botoi API is down?
- The middleware catches network errors and lets the request through. This fail-open approach means a temporary API outage never blocks legitimate users from signing up. You can add logging to track when fallback behavior kicks in.
- Can I use this with other frameworks like Remix or SvelteKit?
- The API call itself works from any server-side environment. The middleware pattern shown here is Next.js-specific, but the core logic (POST to the endpoint, check is_disposable in the response) translates directly to Remix loaders, SvelteKit hooks, or Express middleware.
- How accurate is the disposable email detection?
- The endpoint checks against a list of 700+ known disposable domains and uses pattern matching to catch variations. It also identifies free email providers (Gmail, Outlook, Yahoo) separately from disposable ones, so you can distinguish between a personal Gmail and a throwaway Mailinator address.
Try this API
Disposable Email API — interactive playground and code examples
More integration posts
Start building with botoi
150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.