Labels.io
Agent skills

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/xxxxx

3. 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.com

3. 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.

On this page