Decision Record: Building the Contact Form
How I structured this site's contact form: validation, rate limiting, email-first delivery, and optional inbox persistence with minimal operational overhead.
Context
The contact form looked simple at the UI layer, but production behavior had to cover several non-trivial constraints:
- prevent abuse without degrading legitimate submissions
- avoid silent data loss between transport and persistence
- keep operational complexity low for a solo-maintained project
I wanted a design that was stable under low-to-moderate traffic and still clear enough to reason about during failures.
Decision
I used a Server Action-based pipeline with this order:
- validate inputs with Zod
- apply honeypot and server-side rate limiting
- send notification email
- persist to database when configured
- return explicit success/error state to the UI
This keeps mutation logic server-side while avoiding custom API route complexity.
Tradeoffs Considered
Option A: API Route + client fetch
Pros:
- familiar REST shape
- easy to test independently
Cons:
- more boilerplate for this scope
- more client/server wiring for state and error handling
Option B: Server Actions (chosen)
Pros:
- less transport boilerplate
- direct form integration with React state flow
- cleaner mutation ownership in one place
Cons:
- fewer teams are deeply familiar with this pattern
- requires careful handling of returned action state
What This Handles
Spam is controlled by a honeypot field plus Upstash-backed server-side rate limiting, no CAPTCHAs. Validation runs through a single Zod schema on the server, so the client can't submit a shape the backend doesn't expect. The UI only flips to success after Resend accepts the send, so nobody gets a false green checkmark. And because feature availability is env-driven, the whole pipeline degrades gracefully when database or Redis config is missing.
Where It Stands
The pipeline handles contact reliably without a custom API layer. Email is the source of truth. If the DB write fails after a successful send, the user still gets their confirmation and I still get the email. The admin inbox might miss that entry, but that's an acceptable tradeoff for a solo project where email delivery matters more than inbox completeness.
What I Would Improve Next
- add structured error categories for observability
- record lightweight submission telemetry for anomaly analysis
- add queue-backed async delivery path if traffic profile changes