Block disposable emails in Next.js and Express (middleware, no API key)
Block throwaway email signups in Next.js and Express with no API key: one small module, a free API call, a free list of 224,174 domains and tested code.
To block disposable emails in Next.js or Express, ask a free API about the domain and keep a list of throwaway domains in a Set for when the API cannot answer. One module with no packages does both. Three short files wire it into an Express route, a Next.js route handler and a server action, and none of them needs an API key. The module lets relay addresses such as Apple's Hide My Email through.
#Should you use an npm package for it?
Most npm packages bake their list into each release, so the list ages until you update the package. We counted the lists inside the most downloaded ones on October 25, 2026, read straight from their files:
- FFraud MIT list224,174 domains
- disposable-domains134,575 domains
- disposable-email-domains121,558 domains
- mailchecker56,359 domains
- fakeout9,927 domains
- disposable-email-domains-js8,883 domains
Source: npm registry tarballs and download counts, read by FFraud on October 25, 2026; FFraud's list, October 26, 2026
| Package | Last published | Downloads a week |
|---|---|---|
| disposable-email-domains | September 28, 2022 | 433,794 |
| mailchecker | July 18, 2026 | 235,903 |
| disposable-email-domains-js | September 21, 2026 | 189,831 |
| disposable-domains | October 19, 2026 | 14,869 |
| fakeout | October 24, 2026 | 8,305 |
The most downloaded one has not had a release since 2022, and deep-email-validator depends on it. free-email-domains, with 348,084 downloads a week, lists free providers too, among them gmail.com, outlook.com and yahoo.com. Blocking on it would turn away ordinary users.
FFraud's list held 224,174 domains on October 26, 2026, is MIT licensed and is refreshed every 30 minutes. The module below downloads it at runtime, so it never waits for a package release, and it asks the free API first.
#How do you build the check?
// Block disposable email addresses in Node.js: FFraud's free API decides, its MIT list in a Set answers when the API cannot.
// No packages. Works in Express and in the Next.js Node.js runtime (the default).
const LIST_URL = "https://raw.githubusercontent.com/FFraud-com/disposable-email-domains/main/disposable-email-domains.txt";
const API_URL = "https://api.ffraud.com/public/email/check";
const DAY = 24 * 3600 * 1000;
// Relay services such as Apple's Hide My Email forward to an inbox a real person reads. Set to false to refuse them too.
const ACCEPT_RELAYS = true;
const EMAIL = /^(?!\.)(?!.*\.\.)[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+(?<!\.)@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
let domains = new Set();
let nextRefresh = 0;
let pending = null;
let firstTry = true;
const types = new Map();
async function download() {
const res = await fetch(LIST_URL, { signal: AbortSignal.timeout(10000) });
if (!res.ok) throw new Error(`list download answered ${res.status}`);
const set = new Set();
for (const line of (await res.text()).split("\n")) {
const d = line.trim().replace(/\.$/, "").toLowerCase();
if (d && !d.startsWith("#")) set.add(d);
}
if (set.size < 100000) throw new Error(`only ${set.size} domains in the download`);
return set;
}
function refreshSoon() {
if (pending || Date.now() < nextRefresh) return pending;
pending = download()
.then((set) => { domains = set; nextRefresh = Date.now() + DAY; })
.catch((e) => { nextRefresh = Date.now() + 5 * 60 * 1000; console.warn(`disposable list: ${e.message}`); })
.finally(() => { pending = null; });
return pending;
}
export async function isListed(domain) {
const waiting = refreshSoon();
if (firstTry && waiting) { firstTry = false; await waiting; } // only the first request waits for the download
const labels = domain.trim().replace(/\.$/, "").toLowerCase().split(".");
for (let i = 0; i < labels.length - 1; i++) if (domains.has(labels.slice(i).join("."))) return true;
return false;
}
export function looksLikeEmail(email) {
const at = email.lastIndexOf("@");
return email.length <= 254 && at >= 1 && at <= 64 && EMAIL.test(email);
}
// "temp", "alias" or "normal", kept for a day per domain; null when the API cannot answer.
// The type depends on the domain alone, so the API gets user@ and the domain, never the address.
export async function apiType(domain, timeoutMs = 2000) {
const hit = types.get(domain);
if (hit && Date.now() < hit.until) return hit.type;
try {
const res = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: `user@${domain}` }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
const type = (await res.json()).email_type;
if (typeof type !== "string") return null;
types.set(domain, { type, until: Date.now() + DAY });
return type;
} catch {
return null;
}
}
export async function checkEmail(input, timeoutMs = 2000) {
const email = String(input ?? "").trim();
if (!looksLikeEmail(email)) return { accept: false, why: "not an email address" };
refreshSoon(); // keeps the list current in the background, for the moments the API cannot answer
const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
const type = await apiType(domain, timeoutMs);
if (type === null) {
// The list cannot tell a relay from a throwaway inbox, so it refuses both.
if (await isListed(domain)) return { accept: false, why: "disposable domain" };
return { accept: true, why: "API unavailable, list only" };
}
if (type === "temp" || (type === "alias" && !ACCEPT_RELAYS)) return { accept: false, why: "disposable domain" };
return { accept: true, why: type === "alias" ? "relay address" : "ok" };
}- The API decides.
apiType()sendsuser@and the domain, never the address, and keeps each answer for a day:temp,aliasornormal.AbortSignal.timeout()gives the call 2 seconds (MDN). - Relays pass. Set
ACCEPT_RELAYSto false to refuse relay services too. - The list waits in memory.
checkEmailstarts the download in the background, and the list refreshes itself once a day. In our test the download took 298 ms, and a lookup about 0.5 microseconds. - Parent domains count.
inbox.mailinator.commatchesmailinator.com. - A failed download costs nothing. The module tries again 5 minutes later, and the API answers in the meantime. A download under 100,000 domains is refused.
- Format first. A malformed address is refused before any lookup, with the pattern from our
email validation regex guide .
FFraud's list holds relay services such as privaterelay.appleid.com, duck.com and mozmail.com, and the API answers alias for them. A relay forwards to an inbox a real person reads, and our guide to
The list takes memory: the heap grew by about 38 MB in our test. That is fine on a server, and it is the reason the check belongs on the server, never in the browser.
#How do you add it to Express?
import { checkEmail } from "./disposable.mjs";
// Use after express.json() or express.urlencoded(): app.post("/signup", express.json(), blockDisposableEmail(), handler)
export function blockDisposableEmail(field = "email") {
return async (req, res, next) => {
const verdict = await checkEmail(req.body?.[field]);
if (!verdict.accept) return res.status(400).json({ error: verdict.why });
req.emailCheck = verdict;
next();
};
}Express middleware receives req, res and next, and it must either answer or call next() (Express docs). Place it after express.json() or express.urlencoded(), which parse the body. A throwaway address gets a 400 with the reason, and the signup handler never runs.
#How do you add it to a Next.js route handler?
import { checkEmail } from "../../../lib/disposable.mjs";
export async function POST(request) {
const { email } = await request.json().catch(() => ({}));
const verdict = await checkEmail(email);
if (!verdict.accept) return Response.json({ error: verdict.why }, { status: 400 });
// create the account here
return Response.json({ ok: true });
}Route handlers use the Web Request and Response APIs, and they run in the Node.js runtime unless you change it (Next.js docs). Keep that default, since the edge runtime is deprecated and a 38 MB list does not belong there. A body that is not JSON is answered like a broken address.
#And in a server action?
"use server";
import { checkEmail } from "../../lib/disposable.mjs";
// For a form with useActionState: the action receives the previous state, then the form data.
export async function signup(prevState, formData) {
const verdict = await checkEmail(formData.get("email"));
if (!verdict.accept) return { error: `Please use another email address (${verdict.why}).` };
// create the account here
return { ok: true };
}With useActionState, a server function receives the previous state first and the form data second (Next.js docs). The action returns an error message the form can show, so a person with a throwaway address knows what to change.
#What happens when the API or the list download fails?
Signups go on. If the API fails or takes longer than 2 seconds, the list decides: a listed domain is refused, and any other address is accepted. The list cannot tell a relay from a throwaway inbox, so relay addresses are refused until the API answers again.
If the list cannot be downloaded, the API decides alone, and the module tries the download again 5 minutes later. In our test with GitHub cut off, the API still refused test@mailinator.com.
Failing closed would stop every signup during an outage, which costs more than one new throwaway service. A confirmation email still decides whether the inbox works.
#How do you test it?
We ran the module and the three entry points on Node.js 22 on October 26, 2026, with one API call a second at most. Three more runs cut off GitHub, cut off the API, and set ACCEPT_RELAYS to false:
Node v22.19.0 2026-10-26T00:25:59.183Z
first isListed (downloads the list): true 298 ms
heap grew by about 38.3 MB
isListed("mailinator.com") = true, 0.52 microseconds per call
isListed("gmail.com") = false, 0.50 microseconds per call
isListed("inbox.mailinator.com") = true, 0.45 microseconds per call
checkEmail("test@mailinator.com") -> {"accept":false,"why":"disposable domain"} 350.4 ms, API calls: 1
checkEmail("user@inbox.mailinator.com") -> {"accept":false,"why":"disposable domain"} 304.6 ms, API calls: 1
checkEmail("test@privaterelay.appleid.com") -> {"accept":true,"why":"relay address"} 836.3 ms, API calls: 1
checkEmail(" team@ffraud.com\n") -> {"accept":true,"why":"ok"} 188.3 ms, API calls: 1
checkEmail("team@ffraud.com") -> {"accept":true,"why":"ok"} 0.0 ms, API calls: 0
checkEmail("us..er@ffraud.com") -> {"accept":false,"why":"not an email address"} 0.0 ms, API calls: 0
what the API received: {"email":"user@mailinator.com"} {"email":"user@inbox.mailinator.com"} {"email":"user@privaterelay.appleid.com"} {"email":"user@ffraud.com"}
express test@mailinator.com: {"status":400,"json":{"error":"disposable domain"},"nextCalled":false}
express team@ffraud.com: {"nextCalled":true,"emailCheck":{"accept":true,"why":"ok"}}
express test@privaterelay.appleid.com: {"nextCalled":true,"emailCheck":{"accept":true,"why":"relay address"}}
route POST {"email":"test@mailinator.com"} -> 400 {"error":"disposable domain"}
route POST {"email":"team@ffraud.com"} -> 200 {"ok":true}
route POST not json -> 400 {"error":"not an email address"}
action signup(test@mailinator.com) -> {"error":"Please use another email address (disposable domain)."}
action signup(team@ffraud.com) -> {"ok":true}
API latency, 20 calls: median 198 ms, min 138 ms, max 594 ms
disposable list: network down
list download fails, test@mailinator.com -> {"accept":false,"why":"disposable domain"}
list download fails, team@ffraud.com -> {"accept":true,"why":"ok"}
API down, test@mailinator.com -> {"accept":false,"why":"disposable domain"}
API down, test@privaterelay.appleid.com -> {"accept":false,"why":"disposable domain"}
API down, team@ffraud.com -> {"accept":true,"why":"API unavailable, list only"}
ACCEPT_RELAYS false, test@privaterelay.appleid.com -> {"accept":false,"why":"disposable domain"}Express and Next.js were not installed for this test. The middleware ran with stand-in req and res objects, and the route handler and the server action ran as plain functions over Node's own Request, Response and FormData. Run the route once inside your own app before you ship it.
#Questions people ask
Which npm package should I use to detect disposable emails?
Check the package's last release first: the most downloaded one in our count was last published in 2022. A list fetched at runtime, like the module above, stays current without a release.
Is the disposable-email-domains npm package still maintained?
Its latest version, 1.0.62, was published on September 28, 2022, according to the npm registry. It held 121,558 domains when we counted it.
How do I block disposable emails in a Next.js signup?
Call a check at the top of the route handler or server action that creates the account, before anything is written, and return an error the form can show. The code above does both.
Should the check run in Next.js middleware, a route handler or a server action?
Where the account is created: the route handler or the server action. There the body is already parsed and the error can go straight back to the form.
Will this block Apple's Hide My Email?
Not as shipped. The API answers alias for relay services, and checkEmail accepts them unless you set ACCEPT_RELAYS to false. While the API is down, the list refuses them.
How do I validate an email in a Next.js API route?
Parse the JSON body, trim the address, check its format and length, then check its domain. checkEmail above does those steps in that order.
Should I also check the domain's MX records?
An MX check answers another question: whether the domain takes mail at all. Throwaway services do, so they pass it: on October 26, 2026, mailinator.com listed two MX records. The check does stop a domain like example.com, whose null MX says it accepts no mail (RFC 7505). The
To test one address by hand, paste it into the free
Sources
route.js (Route Handlers) Route Segment Config: runtime Forms Writing middleware for use in Express apps express.json() AbortSignal.timeout() Package metadata and download counts Email checks API disposable-email-domains, 224,174 domains RFC 7505: A "Null MX" No Service Resource Record for Domains That Accept No Mail