ContactLintAPIMCPPricingFree toolsCompareGet a key

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:

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.

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

  1. 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".
  2. Send the same address with "confirmed_email":"jane@gmial.com". Expect 422 with the permanent address message, because the domain is also disposable.
  3. Try x@mailinator.com. Expect the permanent address message.
  4. Try an address on your own company domain. Expect 201.
  5. Test fail open: set CONTACTLINT_KEY to 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

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

Get a free API key. 100 checks per month, no card required. Or try a live check first.