wing architecture uses TypeScript, edge-compatible serverless functions, and a lightweight message queue to ensure reliability, observability, and provider neutrality.
Step 1: Define the Payload Schema and Validation Layer
Never trust client-side data. Implement strict schema validation at the edge to reject malformed or malicious payloads before they reach downstream services.
// src/validators/contact-schema.ts
import { z } from 'zod';
export const ContactPayloadSchema = z.object({
idempotencyKey: z.string().uuid(),
fullName: z.string().min(2).max(100),
email: z.string().email(),
department: z.enum(['engineering', 'sales', 'support']),
message: z.string().min(10).max(2000),
timestamp: z.number().int().positive()
});
export type ContactPayload = z.infer<typeof ContactPayloadSchema>;
Rationale: Idempotency keys prevent duplicate processing during network retries. Strict type enforcement blocks injection attempts and reduces downstream parsing errors. Using zod provides runtime validation with TypeScript type inference, eliminating manual typeof checks.
Step 2: Implement the Edge Handler
The handler should validate, filter, enqueue, and respond synchronously. Heavy operations (email dispatch, database writes) must be asynchronous.
// src/handlers/contact-ingestor.ts
import { ContactPayloadSchema, ContactPayload } from '../validators/contact-schema';
import { SpamFilter } from '../services/spam-filter';
import { DeliveryQueue } from '../services/delivery-queue';
import { createLogger } from '../utils/logger';
const logger = createLogger('contact-ingestor');
export async function POST(request: Request) {
try {
const rawBody = await request.json();
const validatedPayload = ContactPayloadSchema.parse(rawBody);
// Rate limiting check (implementation depends on provider)
const isRateLimited = await checkRateLimit(validatedPayload.email);
if (isRateLimited) {
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 });
}
// Spam evaluation
const spamScore = await SpamFilter.evaluate(validatedPayload);
if (spamScore > 0.85) {
logger.warn('Spam blocked', { email: validatedPayload.email, score: spamScore });
return new Response(JSON.stringify({ status: 'accepted' }), { status: 202 });
}
// Enqueue for async processing
await DeliveryQueue.push({
payload: validatedPayload,
priority: spamScore < 0.3 ? 'high' : 'normal'
});
logger.info('Submission queued', { key: validatedPayload.idempotencyKey });
return new Response(JSON.stringify({ status: 'accepted' }), { status: 202 });
} catch (error) {
if (error instanceof z.ZodError) {
return new Response(JSON.stringify({ error: 'Validation failed', details: error.errors }), { status: 400 });
}
logger.error('Ingestion failure', { error });
return new Response(JSON.stringify({ error: 'Internal processing error' }), { status: 500 });
}
}
Rationale: Returning 202 Accepted immediately decouples the frontend from downstream latency. Spam filtering runs synchronously but uses a lightweight heuristic model to avoid blocking the request. Rate limiting protects against credential stuffing and form scraping. Structured logging ensures observability without coupling to a specific monitoring vendor.
Step 3: Async Delivery Router
A separate worker or scheduled function consumes the queue, handles email dispatch, database persistence, and webhook notifications.
// src/workers/delivery-router.ts
import { DeliveryQueue, QueuedSubmission } from '../services/delivery-queue';
import { EmailProvider } from '../providers/email-provider';
import { AuditStore } from '../storage/audit-store';
export async function processQueue() {
const batch = await DeliveryQueue.consume(10);
for (const item of batch) {
try {
await EmailProvider.send({
to: 'team@company.com',
subject: `New ${item.payload.department} inquiry from ${item.payload.fullName}`,
body: item.payload.message,
replyTo: item.payload.email
});
await AuditStore.record({
idempotencyKey: item.payload.idempotencyKey,
status: 'delivered',
processedAt: Date.now()
});
await DeliveryQueue.ack(item.id);
} catch (deliveryError) {
await DeliveryQueue.retry(item.id, { maxAttempts: 3, backoff: 'exponential' });
}
}
}
Rationale: Separating ingestion from delivery enables retry logic, exponential backoff, and dead-letter queue handling. The audit store maintains a tamper-evident log of every submission, satisfying compliance requirements. Acknowledgment only occurs after successful delivery, preventing data loss during transient failures.
Architecture Decisions
- Edge-First Validation: Running schema checks at the edge reduces compute costs and blocks malicious payloads before they reach application servers.
- Async Queueing: Synchronous email dispatch introduces latency and failure points. Queuing ensures the frontend receives an immediate response while background workers handle delivery.
- Idempotency by Default: Network retries, browser refreshes, and mobile connectivity drops cause duplicate submissions. UUID-based idempotency keys guarantee exactly-once processing.
- Provider-Agnostic Design: The architecture avoids hardcoding Vercel, Netlify, or Cloudflare APIs. Queue adapters and email providers can be swapped without modifying the ingestion layer.
Pitfall Guide
1. Hardcoding Secrets in Client-Side Bundles
Explanation: Developers frequently embed API keys or webhook URLs directly in frontend code for convenience. Static site bundlers inline these values, exposing them to anyone who inspects network traffic or source maps.
Fix: Route all sensitive operations through serverless functions or edge handlers. Use environment variables injected at build/deploy time, and never expose backend credentials to the browser.
2. Ignoring Idempotency and Duplicate Submissions
Explanation: Mobile networks and unreliable connections trigger automatic retries. Without idempotency keys, the same form submission processes multiple times, inflating metrics and spamming inboxes.
Fix: Generate a UUID on the client before submission. Validate and deduplicate against a short-lived cache (Redis, Cloudflare KV, or Upstash) before processing.
3. Treating Email Delivery as Synchronous
Explanation: Awaiting email provider responses inside the form handler blocks the request thread. If the provider experiences latency or rate limits, the frontend receives a timeout or 504 error.
Fix: Decouple delivery using a message queue or background worker. Return 202 Accepted immediately and handle retries asynchronously with exponential backoff.
Explanation: Public forms attract automated scrapers, crypto spam, and credential stuffing bots. Basic honeypots or simple CAPTCHAs are easily bypassed by modern bot networks.
Fix: Implement multi-layer filtering: behavioral analysis (mouse movement, typing speed), disposable email detection, pattern matching for known spam signatures, and threshold-based rate limiting.
5. Skipping Rate Limiting and Abuse Prevention
Explanation: Without request throttling, a single IP or email address can flood the system, exhausting email quotas and triggering provider blocks.
Fix: Apply sliding-window rate limits per IP and per email address. Use edge-native rate limiting (Cloudflare, Vercel Edge Config) or a distributed counter (Redis) to enforce thresholds.
Explanation: Built-in form handlers (Netlify, Webflow, Framer) often lack transparent rate limits, audit logs, or webhook reliability. When traffic spikes, submissions may drop silently or hit undocumented caps.
Fix: Treat platform-native forms as prototyping tools. For production, route through a controlled ingestion layer that provides observability, retry logic, and provider neutrality.
7. Neglecting Data Retention and Compliance
Explanation: Form submissions contain PII (names, emails, messages). Storing them indefinitely without retention policies or consent tracking violates GDPR, CCPA, and internal security standards.
Fix: Implement automated data lifecycle policies. Anonymize or purge submissions after a defined period. Log consent timestamps and provide deletion endpoints for compliance audits.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Prototype / Low Volume (<50/mo) | Managed Inbox Service | Fastest setup, minimal engineering overhead | $0β$15/mo |
| Vendor-Neutral Production | Custom Serverless + Queue | Full control, idempotency, observability, no lock-in | $0β$5/mo (infra) |
| Compliance-Heavy / Enterprise | Self-Hosted OSS + VPS | Data sovereignty, custom retention, audit trails | $10β$30/mo (hosting) |
| Platform-Exclusive Workflow | Platform-Native Forms | Zero config, tight integration with existing dashboard | $0β$19/mo |
| High Volume / Marketing Ops | Managed Service + CRM Sync | Pipeline automation, auto-responders, lead tracking | $12β$29/mo |
Configuration Template
# .env.production
FORM_BACKEND_REGION=us-east-1
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=5
SPAM_THRESHOLD=0.85
QUEUE_BATCH_SIZE=10
EMAIL_PROVIDER_API_KEY=sk_live_XXXXXXXXXXXXXXXX
AUDIT_STORE_CONNECTION_STRING=postgresql://user:pass@host:5432/form_audit
LOG_LEVEL=info
// src/config/runtime.ts
export const runtimeConfig = {
region: process.env.FORM_BACKEND_REGION || 'us-east-1',
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5', 10)
},
spam: {
threshold: parseFloat(process.env.SPAM_THRESHOLD || '0.85')
},
queue: {
batchSize: parseInt(process.env.QUEUE_BATCH_SIZE || '10', 10)
},
audit: {
connectionString: process.env.AUDIT_STORE_CONNECTION_STRING || ''
}
};
Quick Start Guide
- Initialize the project: Create a TypeScript monorepo with
zod, @types/node, and your preferred queue adapter (e.g., bullmq, upstash, or provider-native queues).
- Deploy the ingestion handler: Upload the edge/serverless function to your platform. Configure environment variables and attach rate limiting middleware.
- Wire the frontend: Update your static form to POST JSON to the handler endpoint. Include an idempotency key generated via
crypto.randomUUID() before submission.
- Validate and monitor: Submit test payloads with valid, invalid, and spam-like data. Check structured logs, queue metrics, and audit records to confirm exactly-once processing.
- Enable async delivery: Deploy the background worker or scheduled function. Verify email dispatch, retry behavior, and dead-letter queue handling under simulated failures.