Home / Integrations / Next.js
Validate Emails in Next.js Signup Forms (Route Handler and Server Action)
Published 2026-10-18
This guide adds a ContactLint email check to a Next.js App Router signup flow. You get one shared helper, plus a route handler and a server action that call it. The check runs on the server with a 3 second timeout, and it lets the signup through if ContactLint does not answer.
Where the check goes in Next.js
A check in the browser is easy to skip. The check that counts runs on the server, right before you save the address. The App Router gives you two server-side places for it:
- A route handler such as
app/api/signup/route.ts, if your form posts JSON withfetch. - A server action, if your form uses the
actionprop.
Both run only on the server, so your secret ContactLint key never reaches the browser. Put the key in .env.local as CONTACTLINT_KEY. Do not prefix it with NEXT_PUBLIC_, because Next.js exposes those variables to browser code. Get a free key at /signup.
Step 1: a shared check helper
Both the route handler and the server action call this function. It sends one request to POST https://contactlint.com/v1/email/validate and returns ok: true whenever ContactLint does not answer in time or returns an error. Save it as lib/contactlint.ts. The examples import it with the @/ alias, so adjust the path if your project does not use that alias.
type ContactLintResult = {
verdict: 'valid' | 'risky' | 'invalid' | 'unknown';
reason?: string | null;
did_you_mean?: string | null;
disposable?: boolean;
role_address?: boolean;
};
export type CheckResult =
| { ok: true }
| { ok: false; message: string }
| { ok: false; suggestion: string };
export type SignupState = { email?: string; error?: string; suggestion?: string };
const CONTACTLINT_URL = 'https://contactlint.com/v1/email/validate';
export async function checkEmail(email: string, confirmed?: string | null): Promise<CheckResult> {
let result: ContactLintResult | undefined;
try {
const res = await fetch(CONTACTLINT_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CONTACTLINT_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return { ok: true }; // 401, 402, 429 or other error: fail open
({ result } = await res.json());
} catch {
return { ok: true }; // timeout or network error: fail open
}
if (!result) return { ok: true };
if (result.verdict === 'invalid') {
return { ok: false, message: 'That email address cannot receive mail.' };
}
// Check did_you_mean before disposable: gmial.com is a typo and on the disposable list.
if (result.did_you_mean && confirmed !== email) {
return { ok: false, suggestion: result.did_you_mean };
}
if (result.disposable) {
return { ok: false, message: 'Please use a permanent email address.' };
}
return { ok: true }; // valid, unknown, role address, or a kept typo that is not disposable
}
Step 2: call it from a route handler or a server action
Route handler. Save as app/api/signup/route.ts. It returns 422 with either a suggestion or a message.
import { NextResponse } from 'next/server';
import { checkEmail } from '@/lib/contactlint';
export async function POST(request: Request) {
const body = await request.json().catch(() => ({}));
const email = typeof body.email === 'string' ? body.email.trim() : '';
if (!email) {
return NextResponse.json({ ok: false, message: 'Email is required.' }, { status: 400 });
}
const check = await checkEmail(email, body.confirmed_email);
if (!check.ok) return NextResponse.json(check, { status: 422 });
// Save the user here.
return NextResponse.json({ ok: true }, { status: 201 });
}Server action. Save as app/signup/actions.ts.
'use server';
import { redirect } from 'next/navigation';
import { checkEmail, type SignupState } from '@/lib/contactlint';
export async function signup(prev: SignupState, formData: FormData): Promise<SignupState> {
const email = String(formData.get('email') ?? '').trim();
const confirmed = String(formData.get('confirmed_email') ?? '');
if (!email) return { error: 'Enter your email address.' };
const check = await checkEmail(email, confirmed);
if (!check.ok) {
return 'suggestion' in check
? { email, suggestion: check.suggestion }
: { email, error: check.message };
}
// Save the user here.
redirect('/welcome');
}The form is a client component, saved as app/signup/SignupForm.tsx. useActionState comes from react. Older React versions call this hook useFormState and import it from react-dom.
'use client';
import { useActionState, useState } from 'react';
import { signup } from './actions';
import type { SignupState } from '@/lib/contactlint';
export default function SignupForm() {
const [state, formAction, pending] = useActionState(signup, {} as SignupState);
const [email, setEmail] = useState('');
const showSuggestion = Boolean(state.suggestion) && email === state.email;
return (
<form action={formAction}>
<input type="email" name="email" required value={email}
onChange={(e) => setEmail(e.target.value)} />
<input type="hidden" name="confirmed_email" value={showSuggestion ? email : ''} />
{showSuggestion && (
<p>
Did you mean{' '}
<button type="button" onClick={() => setEmail(state.suggestion!)}>
{state.suggestion}
</button>? Or press Sign up again to keep what you typed.
</p>
)}
{state.error && email === state.email && <p role="alert">{state.error}</p>}
<button type="submit" disabled={pending}>Sign up</button>
</form>
);
}
Handling each verdict and did_you_mean
The verdict is valid, risky, invalid or unknown. Every risky result has a reason: possible_typo, disposable or role_address, in that order of precedence.
- invalid: the address is malformed, or its domain has no MX or A record. Show the error under the field.
- risky, possible_typo:
did_you_meanis set. Show it as a button that fills in the fix. If the person presses Sign up again without changing the address, the hiddenconfirmed_emailfield tells the server to skip the suggestion. - risky, disposable: rejected in this example. Remove that branch if you accept temporary addresses.
- risky, role_address (info@, sales@): accepted. It is a real inbox, just a shared one.
- valid or unknown: accepted.
A kept typo still goes through the disposable check. gmial.com is on the disposable list, so keeping jane@gmial.com returns the permanent address message. This is why the helper checks did_you_mean before disposable. If you want the hint before submit, add the widget for React with a cl_pub_ publishable key, and keep this server check as the backstop.
Testing it
- Start the dev server and post a typo to the route handler:
curl -i -X POST http://localhost:3000/api/signup -H "content-type: application/json" -d '{"email":"jane@gmial.com"}'Expect 422 with"suggestion":"jane@gmail.com". - Send the same address with
"confirmed_email":"jane@gmial.com". Expect 422 with the permanent address message, because the domain is also disposable. - Try
x@mailinator.com. Expect the permanent address message. - Try an address on your own company domain. Expect 201.
- Test fail open: set
CONTACTLINT_KEYto a wrong value and restart. ContactLint returns 401 and the signup goes through.
Each test uses one check from your 100 free checks per month.
Limits: no mailbox check, and fail open
- ContactLint does not connect to mail servers. It cannot confirm that a mailbox exists or detect catch-all domains. It checks syntax, MX and A records, known disposable domains, role addresses and typos of major providers. See the signup validation guide for the full rule set.
- The helper fails open on timeouts, network errors, 401, 402 (free checks used up and no credits) and 429 (more than 60 requests per minute on the key). A validation problem never blocks a signup.
- A result of
unknowncaused by a failed DNS lookup is not billed.
Questions
Can I call the ContactLint API from a client component?
Not with your secret key. Anything in a client component ships to the browser. Keep the secret key in a route handler or server action. For hints in the browser, use the ContactLint widget with a cl_pub_ publishable key that only answers requests from origins you list.
Does every submit use a check?
Yes. Each submit that reaches ContactLint uses one check, so a person who keeps a suggested typo uses two. A result of unknown caused by a failed DNS lookup is not billed. Every key gets 100 free checks per month, then credit packs from $9 for 5,000.
What happens when my free checks run out?
ContactLint returns 402 quota_exceeded. The helper treats any non-200 response as fail open, so signups keep working without the check. Buy a credit pack at /buy or wait for next month.
Other integrations
- Express
- Django
- FastAPI
- Laravel
- Ruby on Rails
- Supabase
- Firebase Authentication
- WordPress
- Webflow
- Zapier
- Make
- n8n
Get a free API key. 100 checks per month, no card required. Or try a live check first.