Wire the contact form
The contact form in app/routes/contact.tsx ships as a UI-only shell. This kit skill connects it to a service so messages are actually delivered.
Ask first
Which service?
- Formspree (recommended, easiest): set a POST endpoint, done. formspree.io
- Basin: similar to Formspree, alternative provider. usebasin.com
- Web3Forms: another form-to-email service. web3forms.com
- Resend (own backend): full control, requires adding an API route. resend.com
Flow A — Formspree / Basin / Web3Forms (third-party form endpoint)
1. Get an endpoint
User creates an account, creates a form, copies the endpoint URL.
2. Add env var
In .env:
VITE_CONTACT_FORM_ENDPOINT=https://formspree.io/f/xxxxx3. Update the submit handler
In app/routes/contact.tsx, replace the handleSubmit body:
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const endpoint = import.meta.env.VITE_CONTACT_FORM_ENDPOINT
if (!endpoint) {
setSubmitted(true)
return
}
const formData = new FormData(event.currentTarget)
const response = await fetch(endpoint, {
method: 'POST',
body: formData,
headers: { Accept: 'application/json' },
})
if (response.ok) {
setSubmitted(true)
} else {
// handle error, show message
}
}4. Verify
Submit from localhost, check the provider dashboard or email inbox.
Flow B — Own backend (Resend)
Requires a server route. TanStack Start supports createServerFn.
1. Install
pnpm add resend
2. Env
RESEND_API_KEY=re_xxxxx
CONTACT_TO_EMAIL=you@yourdomain.com
CONTACT_FROM_EMAIL=contact@yourdomain.com3. Create app/lib/send-contact.ts
import { createServerFn } from '@tanstack/react-start'
import { Resend } from 'resend'
export const sendContact = createServerFn({ method: 'POST' }).handler(
async (ctx) => {
const data = ctx.data as { name: string; email: string; message: string }
const resend = new Resend(process.env.RESEND_API_KEY!)
await resend.emails.send({
from: process.env.CONTACT_FROM_EMAIL!,
to: [process.env.CONTACT_TO_EMAIL!],
replyTo: data.email,
subject: `Contact form: ${data.name}`,
text: data.message,
})
return { ok: true }
},
)4. Update handleSubmit
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
await sendContact({
data: {
name: String(formData.get('name')),
email: String(formData.get('email')),
message: String(formData.get('message')),
},
})
setSubmitted(true)
}5. Verify the sender domain
Resend needs the sender domain (contact@yourdomain.com) verified. Follow Resend's domain-verification flow at resend.com/docs/dashboard/domains/introduction. Without verification, emails go to spam or bounce.
Spam protection
All four flows: add a honeypot field (invisible input; if filled, reject). Formspree and Basin offer their own spam protection too.
Remove the "form not wired" notice
Once the form works, remove the "This form is a UI placeholder" line in app/routes/contact.tsx and the success panel's "This kit is a UI only" note.