Validating signup emails in a Next.js server action
· 7 min read · Tutorials
To validate a signup email in Next.js, do it in the server action that handles the form: call an email-classification API with a short timeout, reject on a definite "spam" answer, and decide up front what happens when the API is slow or unavailable. This guide builds that with React's useActionState, a 2-second timeout, backoff on HTTP 429, and a fail-open policy that still flags unchecked accounts for later.
The examples target the App Router as described in the current Next.js forms guide. useActionState is a React 19 API; the App Router in Next.js 15 and later runs on React 19.
What the check should and shouldn't catch
Syntax is the cheap part. <input type="email" required> gets you most of it in the browser, and a basic server check covers people who bypass the form. The useful part is what syntax can't tell you:
- the domain is a known throwaway provider (see the disposable domain category)
- the local part looks machine-generated, like
ccjxx90sze@ - the address is a role or system mailbox like
noreply@orsales@
We'll use pingutil's /v1/lookup with skip_enrichment: true, which returns the category, email_signals and a verdict without the LLM call, so it's fast enough to sit inside a form submit. If you haven't called the API before, the quickstart shows the raw request and response.
The decision rule is simple: reject on verdict.is_spam, accept but tag verdict.low_trust. We don't recommend rejecting role addresses at signup; for some B2B products team@ is a perfectly real customer. Role-based email addresses goes into that trade-off.
Step 1: a server-only client with explicit failure modes
Put the API call in its own module and mark it server-only, so an accidental import from a Client Component fails the build instead of leaking your key.
npm install server-only
Add the key to .env.local (no NEXT_PUBLIC_ prefix, which would expose it to the browser):
PINGUTIL_KEY=pk_live_...
// lib/pingutil.ts
import 'server-only'
type Verdict = {
is_spam: boolean
low_trust: boolean
reason:
| 'disposable_domain'
| 'randomized_local_part'
| 'role_based'
| 'system_generated'
| 'sales_outreach'
| null
}
export type ScreenResult =
| { kind: 'checked'; category: string; verdict: Verdict }
| { kind: 'invalid' }
| { kind: 'unavailable'; why: 'timeout' | 'network' | 'rate_limited' | 'auth' | 'server' }
const TIMEOUT_MS = 2000
// Per server instance. On serverless platforms each instance keeps its own
// copy, which is fine: the goal is to stop hammering the API, not to be exact.
let backoffUntil = 0
export async function screenEmail(email: string): Promise<ScreenResult> {
if (Date.now() < backoffUntil) return { kind: 'unavailable', why: 'rate_limited' }
let res: Response
try {
res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: email, skip_enrichment: true }),
signal: AbortSignal.timeout(TIMEOUT_MS),
cache: 'no-store',
})
} catch (err) {
const timedOut = err instanceof Error && err.name === 'TimeoutError'
return { kind: 'unavailable', why: timedOut ? 'timeout' : 'network' }
}
if (res.status === 429) {
const body = await res.json().catch(() => ({}))
const seconds = Number(body.retry_after_seconds) || 60
backoffUntil = Date.now() + seconds * 1000
return { kind: 'unavailable', why: 'rate_limited' }
}
if (res.status === 400) return { kind: 'invalid' }
if (res.status === 401) {
console.error('pingutil: API key rejected, check PINGUTIL_KEY')
return { kind: 'unavailable', why: 'auth' }
}
if (!res.ok) return { kind: 'unavailable', why: 'server' }
const data = await res.json()
return { kind: 'checked', category: data.category, verdict: data.verdict }
}
A few choices in there are deliberate.
The timeout is 2 seconds. In fast mode the API normally answers well inside that. If it doesn't, the user is already waiting on a spinner and you have a decision to make; a longer timeout just delays it.
429 doesn't trigger a retry. pingutil returns { "error": "rate_limited", "retry_after_seconds": 60 } when a key exceeds 60 requests per minute. Making a signup wait a minute is not an option, and retrying immediately makes the limit worse. So the client records backoffUntil, skips the API until it passes, and returns unavailable straight away. That honours retry_after_seconds without holding the request open.
400 means a bad address. The API returns invalid_input for inputs it can't parse, IP-literal domains and reserved TLDs like .test or .local. None of those belong to a real user.
401 is a config bug, not a user problem. Log it loudly and treat it as unavailable, so a rotated key doesn't silently block every signup.
Step 2: the server action
// app/signup/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { screenEmail } from '@/lib/pingutil'
import { createUser } from '@/lib/users' // your own persistence
export type SignupState = { error?: string; email?: string }
const BASIC_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export async function signup(
_prev: SignupState,
formData: FormData,
): Promise<SignupState> {
const email = String(formData.get('email') ?? '').trim().toLowerCase()
const password = String(formData.get('password') ?? '')
if (!BASIC_EMAIL.test(email) || email.length > 320) {
return { error: 'Enter a valid email address.', email }
}
if (password.length < 12) {
return { error: 'Use at least 12 characters for your password.', email }
}
const screen = await screenEmail(email)
if (screen.kind === 'invalid') {
return { error: 'Enter a valid email address.', email }
}
if (screen.kind === 'checked' && screen.verdict.is_spam) {
return {
error:
screen.verdict.reason === 'disposable_domain'
? 'Temporary email addresses can’t be used. Please use a permanent address.'
: 'We couldn’t accept this email address. Please use a different one.',
email,
}
}
await createUser({
email,
password,
// Persist what you knew at signup time so you can act on it later.
emailCategory: screen.kind === 'checked' ? screen.category : null,
lowTrustReason: screen.kind === 'checked' && screen.verdict.low_trust
? screen.verdict.reason
: null,
needsEmailRecheck: screen.kind === 'unavailable',
})
redirect('/welcome')
}
redirect throws internally to end the action, so keep it outside any try/catch. Also note that a server action is a public POST endpoint. The form is only one way to reach it, which is exactly why the validation lives here and not in the component.
The error copy is specific for disposable domains (the user can fix it by using their real address) and vague for randomized local parts. Telling a bot exactly which heuristic tripped helps the bot. For the reasoning behind that heuristic and where it misfires, see detecting machine-generated email addresses.
Step 3: the form with useActionState
// app/signup/signup-form.tsx
'use client'
import { useActionState } from 'react'
import { signup, type SignupState } from './actions'
const initialState: SignupState = {}
export function SignupForm() {
const [state, formAction, pending] = useActionState(signup, initialState)
return (
<form action={formAction}>
<label htmlFor="email">Work or personal email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={state.email}
aria-describedby="signup-error"
/>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" minLength={12} required />
<p id="signup-error" aria-live="polite">{state.error}</p>
<button type="submit" disabled={pending}>
{pending ? 'Creating account…' : 'Create account'}
</button>
</form>
)
}
// app/signup/page.tsx
import { SignupForm } from './signup-form'
export default function Page() {
return <SignupForm />
}
Returning email in the state and feeding it to defaultValue keeps the field filled after a rejected submit. React resets uncontrolled form fields when a form action completes, so without this the user retypes the address every time.
If you want feedback while the user is still typing, that's a separate client-side call through your own API route. Real-time email feedback in a React signup form covers debouncing and not leaking the key. Keep the server action check regardless.
Fail open or fail closed
When the check can't run (timeout, network error, rate limit, bad key), the code above lets the signup through and sets needsEmailRecheck. That's fail open. Here's how the two choices compare:
| Fail open | Fail closed | |
|---|---|---|
| API outage | Real users sign up normally | Every signup fails until it recovers |
| Abuse during outage | A few throwaway accounts get in | None get in |
| Cleanup | Re-check flagged accounts later | Nothing to clean up |
| Fits | Free tiers, trials, most SaaS | Signups that pay out money or credits immediately |
For most products, losing real signups during someone else's outage costs more than a handful of throwaway accounts you can find and disable an hour later. Fail closed when a single account is directly worth money to an attacker, such as a signup bonus, free compute or SMS credits.
The cleanup is a small scheduled job: select users where needsEmailRecheck is true, call screenEmail again at well under 60 requests per minute, clear the flag on checked results, and suspend or restrict accounts where verdict.is_spam comes back true. Gate the expensive parts of your product (sending email, granting credits) on the flag being clear, and the window an attacker gets during an outage is small.
Checklist before you ship
PINGUTIL_KEYis set in every environment, withoutNEXT_PUBLIC_.lib/pingutil.tsimportsserver-only.- The fetch has a timeout, and every non-200 path returns a defined result.
- A 429 sets a backoff instead of retrying inside the request.
- Unchecked signups are flagged and re-checked by a job.
- Low-trust addresses are stored, not rejected.
The API reference lists every response field if you later want to route on category, for example sending educational addresses to a student plan.
Questions people ask
- Should I validate signup emails in Next.js middleware or in a server action?
- In the server action. That is where you already have the form data and decide whether to create the account, and it runs only on submit. Middleware runs on every matching request and doesn't see the parsed form body.
- Is client-side email validation enough in Next.js?
- No. type=email and a regex only check syntax, and anyone can skip the browser and post to your action directly. Treat client checks as UX and repeat the real check on the server.
- What happens to signups if the email validation API is down?
- That's your choice. The code in this guide fails open: the account is created and flagged for a later re-check, so an outage on a third-party API never blocks real users.