What You’ll Build
At the end of this guide, your React form will:
- Validate emails in real time via the Syvel API
- Display a visual indicator (loading → ✓ valid / ✗ problem)
- Only block submission for clearly problematic addresses (
is_risky === true) - Show an optional warning for suspicious addresses (score 50–64)
- Suggest a typo correction if
did_you_meanis returned (gmial.com→gmail.com) - Follow UX best practices: no intrusive validation, debounce, graceful error handling
All in under 60 lines of code.
Prerequisites
- React project with React Hook Form (
npm install react-hook-form) - A Syvel API key (free, create an account →)
- Node 18+
API Response: Fields Used
The GET /v1/check/{email} endpoint returns:
interface SyvelResult { risk_score: number; // 0-100 is_risky: boolean; // true if risk_score >= project threshold (default: 65) reason: 'safe' | 'disposable' | 'undeliverable' | 'role_account'; did_you_mean: string | null; // e.g. "gmail.com" for "gmial.com" deliverability_score: number; // 0-100 is_free_provider: boolean; is_corporate_email: boolean; is_alias_email: boolean; mx_provider_label: string | null;}Step 1: Create the Validation Hook
Create a file src/hooks/useEmailValidation.ts:
import { useState, useCallback } from 'react';
interface SyvelResult { risk_score: number; is_risky: boolean; reason: 'safe' | 'disposable' | 'undeliverable' | 'role_account'; did_you_mean: string | null; deliverability_score: number;}
interface ValidationState { status: 'idle' | 'loading' | 'valid' | 'warning' | 'error'; message?: string; didYouMean?: string; score?: number;}
export function useEmailValidation(apiKey: string) { const [state, setState] = useState<ValidationState>({ status: 'idle' });
const validate = useCallback(async (email: string): Promise<string | true> => { if (!email || email.length < 5) return true; // Let it pass, format validation handles it
setState({ status: 'loading' });
try { const res = await fetch( `https://api.syvel.io/v1/check/${encodeURIComponent(email)}`, { headers: { 'Authorization': `Bearer ${apiKey}` } } );
if (!res.ok) { // On API error, don't block the user (fail open) setState({ status: 'idle' }); return true; }
const data: SyvelResult = await res.json();
// Typo detected — warn without blocking if (data.did_you_mean) { setState({ status: 'warning', message: `Did you mean ${data.did_you_mean}?`, didYouMean: data.did_you_mean, score: data.risk_score, }); return true; }
if (data.is_risky) { let message = 'This email address is not accepted.'; if (data.reason === 'undeliverable') { message = 'This domain cannot receive emails.'; } else if (data.reason === 'role_account') { message = 'Please use a personal email address.'; } setState({ status: 'error', message, score: data.risk_score }); return message; }
// Moderate score (non-blocking): soft warning if (data.risk_score >= 50) { setState({ status: 'warning', message: 'This address appears to be temporary. Please use your professional email.', score: data.risk_score, }); return true; }
setState({ status: 'valid', score: data.risk_score }); return true;
} catch { // Network error: fail open (never block a legitimate user) setState({ status: 'idle' }); return true; } }, [apiKey]);
return { state, validate };}Key points:
- Fail open: if the Syvel API is unreachable, let the email pass. Never block a legitimate user due to an external server error.
is_risky: the recommended blocking field. The threshold is configurable in your dashboard.did_you_mean: offer the correction in real time rather than blocking.- Score 50–64: visible warning but submission allowed. Useful for B2C forms.
Step 2: Create the Indicator Component
Create src/components/EmailInput.tsx:
import React from 'react';import { UseFormRegister, FieldErrors } from 'react-hook-form';
interface ValidationState { status: 'idle' | 'loading' | 'valid' | 'warning' | 'error'; message?: string; didYouMean?: string;}
interface Props { register: UseFormRegister<any>; errors: FieldErrors; validationState: ValidationState; onDidYouMeanClick?: (suggestion: string) => void; name?: string;}
function StatusIcon({ status }: { status: ValidationState['status'] }) { switch (status) { case 'loading': return <span className="email-icon loading" aria-hidden>⟳</span>; case 'valid': return <span className="email-icon valid" aria-hidden>✓</span>; case 'warning': return <span className="email-icon warning" aria-hidden>⚠</span>; case 'error': return <span className="email-icon error" aria-hidden>✗</span>; default: return null; }}
export function EmailInput({ register, errors, validationState, onDidYouMeanClick, name = 'email' }: Props) { return ( <div className="field-wrapper"> <label htmlFor={name}>Email address</label> <div className="input-wrapper"> <input id={name} type="email" autoComplete="email" aria-invalid={!!errors[name]} aria-describedby={`${name}-hint`} className={`email-input status-${validationState.status}`} {...register(name)} /> <StatusIcon status={validationState.status} /> </div>
{/* RHF error message (invalid format) */} {errors[name] && ( <p id={`${name}-hint`} role="alert" className="field-error"> {errors[name]?.message as string} </p> )}
{/* Syvel warning (non-blocking) */} {validationState.status === 'warning' && !errors[name] && ( <p id={`${name}-hint`} role="status" className="field-warning"> {validationState.message} {validationState.didYouMean && onDidYouMeanClick && ( <button type="button" className="did-you-mean-btn" onClick={() => onDidYouMeanClick(validationState.didYouMean!)} > Use {validationState.didYouMean} </button> )} </p> )} </div> );}Step 3: Assemble the Form
import React from 'react';import { useForm } from 'react-hook-form';import { useEmailValidation } from '../hooks/useEmailValidation';import { EmailInput } from '../components/EmailInput';
const API_KEY = import.meta.env.VITE_SYVEL_API_KEY;
interface FormData { email: string; name: string;}
export function SignupForm() { const { state: emailState, validate } = useEmailValidation(API_KEY);
const { register, handleSubmit, setValue, formState: { errors, isSubmitting }, } = useForm<FormData>({ mode: 'onBlur', // Validate on focus loss, not on every keystroke });
const handleDidYouMean = (suggestion: string) => { const currentEmail = (document.getElementById('email') as HTMLInputElement)?.value; if (currentEmail && currentEmail.includes('@')) { const local = currentEmail.split('@')[0]; setValue('email', `${local}@${suggestion}`, { shouldValidate: true }); } };
const onSubmit = async (data: FormData) => { console.log('Form submitted:', data); // Your signup logic here };
return ( <form onSubmit={handleSubmit(onSubmit)} noValidate> <div className="field-wrapper"> <label htmlFor="name">Name</label> <input id="name" type="text" autoComplete="name" {...register('name', { required: 'Name is required.' })} /> {errors.name && <p className="field-error">{errors.name.message}</p>} </div>
<EmailInput register={register} errors={errors} validationState={emailState} onDidYouMeanClick={handleDidYouMean} name="email" />
<button type="submit" disabled={isSubmitting || emailState.status === 'loading'} > {isSubmitting ? 'Creating account...' : 'Create my account'} </button> </form> );}To wire Syvel validation into the React Hook Form cycle:
// In the EmailInput component, modify register('email') like this:{...register(name, { required: 'Email is required.', pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Invalid email format.', }, validate: async (value) => await validate(value),})}Step 4: Add Debounce
If you validate in onChange mode rather than onBlur, add a debounce to avoid an API call on every keystroke:
import { useMemo } from 'react';import { debounce } from 'lodash-es'; // or your own implementation
// In useEmailValidation, wrap validate with debounce:const debouncedValidate = useMemo( () => debounce(validate, 500), [validate]);In onBlur mode (recommended), debounce isn’t necessary — validation only triggers when the user leaves the field.
Environment Variables
In your .env.local:
VITE_SYVEL_API_KEY=sv_your_key_hereNever commit your API key. Add .env.local to your .gitignore. In production, inject the variable via your CI/CD pipeline.
For applications where the key would be exposed client-side, prefer routing the API call through your own backend (BFF) that holds the key server-side.
Final Result
With this integration, your form:
| Case | Behavior |
|---|---|
Valid email (is_risky: false, score < 50) | Green indicator ✓, submission allowed |
| Suspicious email (score 50–64) | Warning ⚠, submission allowed |
Typo detected (did_you_mean) | Interactive suggestion, submission allowed |
Risky email (is_risky: true) | Error ✗, submission blocked |
| API unavailable | Fail open, submission allowed |
| Malformed email | Local regex error, no API call |