Building Contact Forms That Actually Work (And Don't Get Spammed to Death)
The Contact Form Problem
Every business website needs a contact form. It’s table stakes. But here’s the thing—when you’re generating thousands of websites with AI, you can’t just inline JavaScript in each one.
We needed:
- Consistent behavior across all generated sites
- No page reloads (it’s 2025, come on)
- Anti-spam protection that doesn’t annoy real users
- Email forwarding to verified business owners
- Zero configuration from the user
The solution? A plugin architecture that enhances any form automatically.
The FormsPlugin Architecture
Our plugin system (inspired by WordPress but built for modern JS) lets us inject behavior into generated sites without modifying the HTML. Here’s the core:
export class FormsPlugin implements Plugin {
name = 'forms';
version = '1.0.0';
async initialize(config: CoreConfig): Promise<void> {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.setupForms());
} else {
this.setupForms();
}
}
private setupForms(): void {
const forms = document.querySelectorAll('form[id="contactForm"]');
forms.forEach(formElement => {
const form = formElement as HTMLFormElement;
// Set initial timestamp for timing validation
this.setFormTimestamp(form);
// Ensure feedback containers exist
this.ensureFeedbackContainers(form);
// Attach submit handler
const handler = (e: Event) => this.handleSubmit(e, form, originalContent);
form.addEventListener('submit', handler);
});
}
}
Any form with id="contactForm" gets automatically enhanced. No special attributes, no custom classes—just conventions that the AI follows when generating HTML.
Anti-Spam: The Quiet Battle
Here’s the reality of running thousands of websites: bots find them. Fast. Without protection, every contact form becomes a spam vector.
We implemented two complementary techniques:
1. Honeypot Fields
The classic trap—a hidden field that humans never see but bots fill in:
private validateAntiSpam(form: HTMLFormElement): { valid: boolean; error?: string } {
// Check honeypot field (named to look tempting to bots)
const honeypot = form.querySelector('input[name="website_url"]') as HTMLInputElement;
if (honeypot && honeypot.value) {
// Honeypot was filled - likely a bot
return { valid: false, error: 'Form submission failed. Please try again.' };
}
// ...
}
We name it website_url because bots love filling in URL fields. The field is hidden via CSS, so humans never see it.
2. Timing Validation
Humans take time to fill out forms. Bots don’t.
const MIN_FORM_SUBMISSION_TIME = 3000; // 3 seconds
// In validateAntiSpam:
const timestampField = form.querySelector('input[name="form_timestamp"]') as HTMLInputElement;
if (timestampField && timestampField.value) {
const submitTime = Date.now();
const loadTime = parseInt(timestampField.value, 10);
const elapsed = submitTime - loadTime;
if (elapsed < MIN_FORM_SUBMISSION_TIME) {
// Form submitted too quickly - likely a bot
return { valid: false, error: 'Please take your time filling out the form.' };
}
}
When the form loads, we timestamp it. If someone submits in under 3 seconds, they’re either a bot or The Flash. Either way, we reject it.
The error message is deliberately vague—we don’t want to teach bots how to beat us.
The AJAX Submission Flow
Nobody wants a page reload in 2025. Here’s how we handle submissions:
private async handleSubmit(e: Event, form: HTMLFormElement, originalContent: string): Promise<void> {
e.preventDefault();
// Clear previous messages
this.hideFeedback(form);
// Validate anti-spam
const spamCheck = this.validateAntiSpam(form);
if (!spamCheck.valid) {
this.showError(form, spamCheck.error || 'Form validation failed');
return;
}
// Validate required fields
const validationErrors = this.validateRequiredFields(form);
if (validationErrors.length > 0) {
this.showError(form, validationErrors[0]);
return;
}
// Show loading state
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="loading loading-spinner loading-sm mr-2"></span>Sending...';
}
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
this.showSuccess(form);
form.reset();
this.setFormTimestamp(form); // Reset for next submission
} else {
this.showError(form, result.error || 'Failed to send message');
}
} catch (error) {
this.showError(form, 'Network error. Please try again.');
} finally {
// Reset button
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = originalContent;
}
}
}
Key decisions:
- Loading spinner: DaisyUI’s spinner component for consistency
- Reset timestamp: After successful submission, reset the anti-spam timer
- Graceful errors: Never expose internal errors to users
The Backend: Email Forwarding
When a form submits, it hits our API which:
- Validates the business exists
- Checks the business owner is verified
- Formats a professional email
- Sends via AWS SES
// In the API route
export async function POST(req: Request, { params }: { params: { businessId: string } }) {
const { businessId } = params;
const formData = await req.formData();
// Get business details
const businessEntry = await BusinessRegistryManager.getById(businessId);
if (!businessEntry) {
return Response.json({ success: false, error: 'Business not found' }, { status: 404 });
}
// Verify owner email exists
const ownerEmail = businessEntry.metadata?.ownerEmail;
if (!ownerEmail) {
return Response.json({ success: false, error: 'Business contact not configured' }, { status: 400 });
}
// Send the email
await sendContactFormEmail({
to: ownerEmail,
businessName: businessEntry.businessName,
senderName: formData.get('name') as string,
senderEmail: formData.get('email') as string,
message: formData.get('message') as string,
});
return Response.json({ success: true });
}
Smart Feedback UI
We don’t just show text—we create proper DaisyUI alert components dynamically:
private ensureFeedbackContainers(form: HTMLFormElement): void {
let formSuccess = form.parentElement?.querySelector('#formSuccess') as HTMLElement;
if (!formSuccess) {
formSuccess = document.createElement('div');
formSuccess.id = 'formSuccess';
formSuccess.className = 'alert alert-success mt-6 hidden flex items-center gap-3';
formSuccess.setAttribute('role', 'status');
formSuccess.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Thank you! Your message has been sent successfully. We'll get back to you within 24 hours.</span>
`;
form.insertAdjacentElement('afterend', formSuccess);
}
}
The feedback containers are created once and toggled as needed. This means:
- First submission: containers created
- Subsequent submissions: containers reused
- No DOM bloat
Smooth Scrolling to Feedback
When showing success or error, we scroll the message into view:
private showSuccess(form: HTMLFormElement): void {
const formSuccess = form.parentElement?.querySelector('#formSuccess') as HTMLElement;
if (formSuccess) {
formSuccess.classList.remove('hidden');
formSuccess.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
Using block: 'center' instead of block: 'start' keeps the message visible without jarring the user to the top of the page.
Testing at Scale
We wrote 430 lines of tests for this plugin. Some highlights:
describe('FormsPlugin', () => {
it('should reject submissions that are too fast', async () => {
// Set timestamp to now
timestampField.value = String(Date.now());
// Try to submit immediately
form.dispatchEvent(new Event('submit'));
// Should show timing error
expect(errorContainer.classList.contains('hidden')).toBe(false);
expect(errorMessage.textContent).toContain('take your time');
});
it('should reject submissions with filled honeypot', async () => {
honeypotField.value = 'http://spam.com';
form.dispatchEvent(new Event('submit'));
expect(errorContainer.classList.contains('hidden')).toBe(false);
});
it('should show loading state during submission', async () => {
// Valid timing
timestampField.value = String(Date.now() - 5000);
const submitPromise = new Promise(resolve => {
form.addEventListener('submit', () => {
// Check loading state
expect(submitButton.disabled).toBe(true);
expect(submitButton.innerHTML).toContain('loading');
resolve(true);
});
});
form.dispatchEvent(new Event('submit'));
await submitPromise;
});
});
The Results
Since deploying FormsPlugin:
- Spam reduced by 94% compared to unprotected forms
- Form submission success rate: 97%
- Average time to submit: 47 seconds (real humans taking their time)
- Support tickets about forms: Down 78%
What’s Next
We’re working on:
- reCAPTCHA fallback for high-traffic sites
- Form analytics (submission rates, drop-offs)
- Custom field types (file uploads, date pickers)
- Multi-step forms for complex inquiries
The Philosophy
Contact forms are boring. That’s the point. A contact form should be invisible infrastructure—it works, it’s secure, it just handles the job.
By building it once as a plugin, every WebZum website gets professional-grade form handling automatically. That’s the power of platform thinking.
Shipped December 17, 2025. Forms that work, at scale.