Remix Form Validation: Adding Real-Time Email Verification to Actions
You Already Have Zod. That’s Not Enough.
Your Remix signup form validates email format with z.string().email(). Zod runs, the schema passes, the user gets through. Feels solid.
z.string().email() checks a regex. It’ll catch user@ and a missing dot. It won’t catch [email protected], [email protected], or [email protected] sitting on a catch-all server that accepts every address regardless of whether the mailbox exists. All three pass Zod. All three bounce the first time you email them.
The fix lives in your action function, running on the server, where your API key is safe and the check happens before you write anything to the database. Sound familiar if you’ve been down this road with Next.js or SvelteKit? Same problem, same server-side solution, different API.
What Remix Actions Give You
Note: Remix merged into React Router in late 2024. React Router v7 is effectively Remix v3: the routing model, actions, loaders, and progressive enhancement are identical. The patterns in this post apply to both. Imports now come from
"react-router"rather than"@remix-run/node"or"@remix-run/react".
Remix actions are exported async functions from your route files. When a form POSTs to a route, Remix calls that route’s action function. The return value flows back to the component via useActionData(). No custom API route, no separate endpoint, no fetch boilerplate in your component.
// app/routes/signup.tsx
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// validate, call APIs, return errors or redirect
}
Two things Remix handles for you: parsing FormData from the request body, and wiring the return value back to useActionData() in the component. You return a plain object. The component reads it. That’s the whole contract.
This is different from Next.js Server Actions, where you define the action with a 'use server' directive and bind it to the form (either via the action prop in React 19, or via useActionState to track pending and error state). Remix keeps the action function at the route level, tied to URL-based routing. Both approaches run server-side code on form submission. The mental model differs; the security properties are the same.
Why does the routing model matter? Because in Remix, every route can have its own action. You don’t need a separate API directory. The form on /signup posts to /signup. The mutation and the UI live in the same file.
The Zod Schema
Define the schema outside the component. Both the action and optional client-side validation import it. One definition, no drift.
// app/lib/schemas/signup.ts
import { z } from "zod";
export const signupSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z
.string()
.min(1, "Email is required")
.email("That doesn't look like a valid email"),
});
export type SignupInput = z.infer<typeof signupSchema>;
This schema is format-only. It’s the first gate, not the last. What it catches: missing @, obvious garbage strings. What it misses: dead domains, disposable addresses, mailboxes that don’t exist on real domains. The email validation API guide breaks down exactly what each layer adds.
The Action Function with MailCop
Here’s the full action. Zod validates format first. Then MailCop checks deliverability. Then the user record gets created.
// app/routes/signup.tsx
import { data, redirect, type ActionFunctionArgs } from "react-router";
import { signupSchema } from "~/lib/schemas/signup";
type ActionErrors = {
name?: string;
email?: string;
form?: string;
};
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const raw = {
name: formData.get("name"),
email: formData.get("email"),
};
// Step 1: Format validation with Zod
const parsed = signupSchema.safeParse(raw);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
return data(
{
errors: {
name: fieldErrors.name?.[0],
email: fieldErrors.email?.[0],
} as ActionErrors,
values: raw,
},
{ status: 400 }
);
}
// Step 2: Deliverability check via MailCop
try {
const response = await fetch("https://api.mailcop.net/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TRUEMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email: parsed.data.email }),
signal: AbortSignal.timeout(3000),
});
if (response.ok) {
const result = await response.json();
if (result.status === "undeliverable") {
return data(
{
errors: { email: "That email address doesn't appear to be deliverable" } as ActionErrors,
values: raw,
},
{ status: 422 }
);
}
if (result.disposable) {
return data(
{
errors: { email: "Disposable email addresses aren't allowed" } as ActionErrors,
values: raw,
},
{ status: 422 }
);
}
}
} catch {
// API timeout or network error: fail open.
// Don't block a real user because the validation service is slow.
}
// Step 3: Create the user and redirect
// await db.user.create({ data: parsed.data });
return redirect("/dashboard");
}
Three decisions worth unpacking.
process.env.TRUEMAIL_API_KEY is available because actions run on the server. Full stop. In Remix, action functions never ship to the browser bundle. Your API key stays server-side without any special configuration. Compare that to client-side validation, where the key would need to be in your frontend code for anyone with DevTools to read.
AbortSignal.timeout(3000) sets a hard 3-second ceiling on the API call. If MailCop times out, the catch block fires and the action proceeds. An external service should never become a single point of failure for your signup flow. Accept the submission, flag the email as unverified, and run a background check later.
The status codes are intentional. 400 for format errors (Zod failures). 422 for semantic errors (email exists but isn’t deliverable). Your component can branch on these if needed, but for most signup forms you just render the error message regardless.
What about returning different error shapes from each branch? That’s fine. TypeScript infers the union type from all your return data(...) calls, and useActionData<typeof action>() reflects that union. Use optional chaining throughout the component and you’re covered.
The Component with useActionData
useActionData<typeof action>() gives you typed access to whatever the action returned. No additional state management, no useState for errors, no useEffect to watch for responses.
// app/routes/signup.tsx (component section)
import {
Form,
useActionData,
useNavigation,
} from "react-router";
import type { action } from "./signup";
export default function SignupPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
defaultValue={actionData?.values?.name?.toString() ?? ""}
/>
{actionData?.errors?.name && (
<p role="alert">{actionData.errors.name}</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={actionData?.values?.email?.toString() ?? ""}
/>
{actionData?.errors?.email && (
<p role="alert">{actionData.errors.email}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Checking..." : "Sign up"}
</button>
</Form>
);
}
useNavigation() replaces the old useTransition() hook from Remix v1. When the form submits, navigation.state moves to "submitting" and back to "idle" when the action completes. That’s your loading state, no manual flags needed.
defaultValue repopulates field values from actionData.values when validation fails. The user typed a name, the email bounced, the name field stays filled in. Small thing. Users notice when it doesn’t work.
Progressive Enhancement. It Actually Works.
Remix’s <Form> component submits via a standard HTML form POST. Without JavaScript, the browser sends the request, the server returns the rendered page with actionData populated, and errors display exactly the same way. No client-side hydration required for the core flow.
Add JavaScript and <Form> upgrades transparently. The submission fires as a fetch request, the page updates without a full reload, useNavigation drives the loading state. The markup doesn’t change. The behavior improves.
This is different from the Next.js Server Actions pattern, where passing a function to the form’s action prop requires React 19 to intercept the submission client-side. Next.js Server Actions also support a basic form of progressive enhancement when you point the form’s action at a URL endpoint, but Remix’s progressive enhancement is built into the routing model itself, with no extra configuration required. The SvelteKit email validation post shows a similar pattern with use:enhance doing the same upgrade from native form POST to fetch-powered submission.
Why Only Syntax and MX in Real Time?
Full SMTP verification takes 500ms to 3 seconds per address. That’s the handshake against the mail server. For a signup form, that latency kills conversions. MX record lookups take under 100ms and catch a large chunk of bad addresses.
Run syntax and MX checks synchronously in the action. Run SMTP verification in a background job. If the deep check fails, trigger a confirmation flow or flag the account for review.
What MX checks catch: domains that don’t exist, domains with no mail server configured, domains that were abandoned. What they miss: mailboxes that don’t exist on a valid domain. The MX vs SMTP validation accuracy post has data on the accuracy difference between these two layers.
For disposable addresses, a blocklist is more reliable than any SMTP check. MailCop tracks over 180,000 disposable domains. New providers spin up faster than SMTP probing can detect them. The result.disposable flag in the code above comes from this blocklist. More detail on how these lists work in the disposable email blocklist post.
Handling Multiple Actions on One Route
Some routes have more than one form. A settings page with a profile form and a password change form, for instance. Remix handles this with named actions.
// app/routes/settings.tsx
import { data, type ActionFunctionArgs } from "react-router";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "update-email") {
// handle email update, validate with MailCop
return { intent: "update-email", errors: {} };
}
if (intent === "change-password") {
// handle password change
return { intent: "change-password", errors: {} };
}
return data({ errors: { form: "Unknown action" } }, { status: 400 });
}
Each form includes a hidden intent field:
<input type="hidden" name="intent" value="update-email" />
The action branches on intent and returns different shapes. In your component, check actionData?.intent to know which form errored. Clean separation without separate routes.
Caching to Avoid Re-Validating the Same Email
Users fix a field and resubmit. You don’t want to hit the validation API twice for the same email address. A module-scoped Map handles this for single-server deployments.
// app/lib/validation-cache.ts
const cache = new Map<string, { status: string; disposable: boolean; ts: number }>();
const TTL_MS = 60 * 60 * 1000; // 1 hour
export function getCached(email: string) {
const key = email.toLowerCase();
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() - entry.ts > TTL_MS) {
cache.delete(key);
return null;
}
return entry;
}
export function setCached(email: string, status: string, disposable: boolean) {
cache.set(email.toLowerCase(), { status, disposable, ts: Date.now() });
}
In the action, check the cache before calling the API:
import { getCached, setCached } from "~/lib/validation-cache";
// inside the action, after Zod passes:
const cached = getCached(parsed.data.email);
const emailResult = cached ?? await callMailCop(parsed.data.email);
if (!cached && emailResult) {
setCached(parsed.data.email, emailResult.status, emailResult.disposable);
}
A Map won’t survive process restarts or scale across serverless instances. Use Redis if you need persistence or horizontal scaling. For a typical Node.js deployment behind a load balancer, this still cuts redundant API calls significantly.
The Full Picture
The whole flow:
- User submits the form.
<Form method="post">sends the POST. - Action runs on the server. Zod validates format. MailCop checks deliverability against MX records and the disposable blocklist.
- Errors return via
data().useActionData()picks them up. Field values repopulate fromactionData.values.useNavigationclears the loading state. - On success,
redirect("/dashboard"). Done.
No client state for errors. No manual fetch calls. No API key in the browser. The form works without JavaScript and upgrades when JavaScript loads.
That’s the Remix pattern. The action function owns the mutation. useActionData owns the error state. Progressive enhancement is free. Compare it to Next.js email validation if you’re evaluating frameworks: the guarantees are similar, the APIs are different enough to matter.