If your contact form's only job is to send an email, every failure in the mail provider is a deleted lead. Record the submission first, then notify — that way losing the provider costs you a notification, not the prospect.
The short version
- A form route that calls the mail API and returns its result has one point of failure and no memory.
- When the key is missing or the provider is down, the visitor sees an error and the submission is gone. Nobody is notified, because notification was the thing that failed.
- Writing the submission to a table before the send attempt turns a total loss into a delayed reply.
- The fix is small. The bug is usually invisible until you go looking, because a form that fails this way leaves no trace of what it lost.
How the failure actually looks
This site's contact route had exactly this shape. It validated the payload, called Resend, and mapped the result to a status code. In production the API key was not set, so the route took its unconfigured branch and answered 503. When we checked in September 2026, every post to it got that 503.
The part worth dwelling on is what the failure left behind. The form did tell the visitor delivery was offline and showed our email address. On our side the failure left nothing behind: no row, no log line naming the visitor, no retry queue. If anyone tried to make contact, all they left was an absence — a form that had never produced a single lead, which reads exactly like a form nobody uses.
That ambiguity is the real cost. "No leads" and "no leads that survived" look identical from the inside.
Capture and notification are different jobs
They fail for different reasons, they have different consequences, and they should not share a code path.
Capture is a write to storage you control. It should happen as early as possible, before any optional work, and it should never be able to take down the request.
Notification is a call to somebody else's service. It will fail sometimes. That is a fact about the world, not a bug to be eliminated.
Once they are separate, the question at the end of the request changes. It stops being "did the email send?" and becomes "is the lead safe?" — and the answer to the second question is the one that decides what the visitor should see.
// Capture first, notify second.
const stored = hasLeadStore() ? await recordLead({ ... }) : "NOT_CONFIGURED";
const captured = stored === null;
if (!hasEmailConfig()) {
// The lead is safe even though nobody has been told yet.
if (captured) return NextResponse.json({ ok: true });
return NextResponse.json({ ok: false, error: "EMAIL_NOT_CONFIGURED" }, { status: 503 });
}The route only returns an error when the submission is genuinely lost — when neither the store nor the mail provider took it. A failure the system has already absorbed is not the visitor's problem to solve.
Make the capture unable to throw
A storage helper that throws moves the fragility rather than removing it. recordLead returns null on success or a short error code, and never raises:
export async function recordLead(lead: LeadRecord): Promise<string | null> {
const config = storeConfig();
if (!config.url || !config.key) return "NOT_CONFIGURED";
try {
const response = await fetch(url, { method: "POST", headers, body });
if (!response.ok) return `STORE_${response.status}`;
return null;
} catch (err) {
return err instanceof Error ? err.message : "UNKNOWN_STORE_ERROR";
}
}The trap this creates, and how to close it
Never throwing is the right call, and it introduces a new way to fail quietly. If the store rejects the write — a wrong key, a wrong project, a missing grant — and the email happens to succeed, the route returns 200, the visitor is thanked, and the lead survives only as an email. The durable copy you built the store for does not exist, and nothing tells you.
So the store's refusals need a detector:
if (stored !== null && stored !== "NOT_CONFIGURED") {
console.error("[contact] lead store failed", { stored });
}"Not configured" is a deliberate state and stays quiet. "Configured and refused" is a misconfiguration somebody has to see.
How to tell whether yours is working
A 200 from the endpoint is not evidence. While email is working, a misconfigured store returns the same 200 as a healthy one — that is the whole point of the design, and it is also why the response cannot be the test.
Select the row. Post one submission with a synthetic address, then query the table directly and confirm it arrived, with the fields you expected. If you only check the status code, you are testing the part that was never going to fail.
What this does not solve
Durable capture protects the lead. It does not deliver the email, and it does not tell the prospect you will be slower than usual. If the provider is down for a day, you have the submissions and you still have not replied to any of them — which is better than the alternative and is not the same as working.
It also raises the value of the store. Once submissions land in a table instead of an inbox, that table holds contact details you are responsible for: enable row-level security, give it no public policies, and let only the server's key write to it. Supabase's secret and service_role keys bypass row-level security, so that key belongs on the server and nowhere else (Supabase, API keys). A lead store that anyone can read is a worse outcome than the bug you started with.


Maestro development preview · synthetic session data