Forms that convert: validation, UX and integration
I spent an afternoon analyzing forms from dental clinics. You know those forms that ask 15 fields before letting you send a single message? Yeah. I’ve lost count of how many people bail halfway through.
Here’s the thing: bad forms cost you customers. I see it every single week in my projects.
Long forms kill your leads before they even start
When you put too many fields in a form, you’re basically apologizing for existing. Someone just wants to book an appointment, not fill out a survey. You need to know exactly how many fields are actually necessary to start the conversation.
For a dental clinic? Three fields work. Name, phone, preferred time. Everything else can wait. When they come back, that’s when you ask for more. That’s conversation. A form is just an opener.
Client-side and server-side validation are not interchangeable
Validation in the browser is fast and gives instant feedback. User types a bad email and sees the red message right away. But never rely on that alone. Anyone can bypass JavaScript if they really want to.
On the server, you validate again. Always. You get the data, clean it, validate each field. In Supabase, I use an edge function that checks type, length, format. If it fails, return a 400 error code. Done.
My rule: client-side for UX, server-side for security.
Loading state separates professionals from amateurs
When a form submits, the button needs to change. Text goes from “Send” to “Sending…”. Button gets disabled. Without this, users click again. And again. You get the same message three times.
In Astro/React, I use a simple state:
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(formData)
});
if (response.ok) {
// success
}
} finally {
setIsLoading(false);
}
};
Sounds trivial. But it works every time.
Clear error messages are not optional
“Error processing form” helps no one. Where did it fail? Email? Phone? Server down?
I tell them exactly what happened:
- “Invalid email”
- “Phone needs 10 digits”
- “That time slot is booked”
They understand and fix it. No frustration.
Wiring it all together: Resend, Supabase and WhatsApp
Once the form validates, I need to do something with the data. My standard flow:
- Save to Supabase: name, email, phone, date/time
- Send email via Resend to the clinic (roughly 0.30 BRL per email after free tier)
- Redirect to clinic’s WhatsApp (no API needed, just
wa.me/55...)
This happens in an API route. In Astro, I create a file at src/pages/api/contact.ts:
export async function POST({ request }) {
const data = await request.json();
// validate
if (!data.name || data.name.length < 2) {
return new Response('Invalid name', { status: 400 });
}
// save to Supabase
await supabase.from('leads').insert([data]);
// send email via Resend
await resend.emails.send({
from: 'noreply@clinic.com',
to: data.email,
subject: 'Your appointment request was received',
html: `Hi ${data.name}...`
});
return new Response(JSON.stringify({ success: true }));
}
Simple and it works every day.
Short forms convert. Long forms ghost you
I built one for Autopars Pro with 4 fields. Conversion? 32%. A client before that had 12 fields. Conversion? 8%.
The gap is huge because we’re actually respecting people’s time.
Visual feedback matters
When the email goes through, I show a green message where the form was. “Message received! You’ll get a WhatsApp from us soon.” This calms people down.
If it fails, I show it in red with exactly what went wrong. Never leave users in the dark.
Your form conversion checklist
- Max 5 fields for first contact
- Browser validation with immediate feedback
- Server validation before saving anything
- Loading state on the button while sending
- Specific error messages, not generic ones
- Email integration (Resend) and WhatsApp redirect
- Test on mobile (most people come from phones)
- Database (Supabase) for lead history
Boring forms that ask for everything upfront get abandoned. You want a conversation, not an interrogation.
Honeypot against bots
One detail that protects against spam: honeypot.
You put a hidden form field via CSS (display: none):
<input type="text" name="website" style="display: none;">
Bots try to fill it. Real users don’t see it.
On the server:
if (data.website) {
// it's a bot, reject
return new Response('Invalid', { status: 400 });
}
Simple, effective, no reCAPTCHA (which bothers users).
Smart redirect vs popup
After form submits:
Option 1: popup saying “Success! Message sent”. Option 2: redirect to thank you page.
Which works better? Redirect.
Why? Because most leads want the conversation to end. Don’t want to read a popup. Want to leave the page.
Redirect to a page saying:
“Your appointment request received! You’ll get a call in 24h. In the meantime, check out our services.”
(and put internal links)
User leaves happy, becomes internal visitor, leaves with another page visited. Better.
Testability: which tool to test with
Resend is good for emailing, but how do you test in dev?
Two ways:
- Sandbox mode: Resend provides sandbox email. Test without actually sending.
- Mailtrap: free tool that captures emails in dev. Shows exactly what was sent.
I use Mailtrap in dev, Resend in production.
Error 422 vs 400: when which
HTTP 400 is “bad request from client side” (invalid email, missing required field).
HTTP 422 is “syntactically valid but semantically impossible” (email exists, user already has appointment at that time).
Difference isn’t obvious. But it’s important for logging.
My standard:
- 400: validation error
- 422: logic conflict
- 500: server error (database down, Resend down, etc)
Then on client show different message:
- 400: “Form error. Check fields.”
- 422: “Time unavailable. Pick another.”
- 500: “Temporary error. Try again.”
Performance: preload resource
Small detail that helps:
<link rel="preload" as="script" href="/api/contact" />
Browser starts reserving the connection to the endpoint before user clicks.
Gains 50ms of latency.
Not much, but it adds up.
Analytics: track drops
Where’s your biggest form drop? Field 1? Field 3? Submit button?
You can track via simple analytics:
field.addEventListener('blur', () => {
analytics.track('form_field_blur', { field: 'email' });
});
If a field has 40% blur but 20% filled, problem is there.
If 80% clicks submit but 60% completes, maybe timeout or error.
Data from these events helps debug where the form dies.
- Implement dual validation (client+server)
- Add honeypot against bots
- Setup Resend and Mailtrap
- Test loading (correct loading state?)
- Track drops with analytics
- Test on mobile (most traffic from phones)
- Implement redirect to thank you page
- Validate specific error messages
Read also: Landing page anatomy that converts | Supabase + React | Integrations: APIs and webhooks
Boring forms that ask for everything upfront get abandoned. You want a conversation, not an interrogation.