Contact form
NewChecks like GOV.UK, keeps every word through a failed send, and sends through your function, Formspree, Web3Forms or no backend at all.
Name, email, a topic and a message, with budget and timeline only for project enquiries. The same rules run in the browser and in the server function (api/contact.ts), so a server error lands under its field like any other. Spam is handled without a CAPTCHA: a hidden honeypot field and the time the visitor started typing travel with the message, and the server drops obvious bots quietly.
Send a message
import { ContactForm, type ContactTransport } from '@rchursin/product/contact'
/** * A pretend backend: it waits a moment, then says the message was sent. Nothing leaves the page. * On a real site, use createContactTransport (see Usage). */const demoTransport: ContactTransport = { canSend: true, send: () => new Promise((resolve) => window.setTimeout(() => resolve({ ok: true }), 900)),}
export default function ContactFormDemo() { return ( <div className="w-full max-w-lg"> <ContactForm transport={demoTransport} email="hello@example.com" replyWindow="within 2 working days" bookingUrl="https://example.com/book" headingLevel={3} note="Demo: messages here go nowhere. On a real site, say what happens to them." /> </div> )}Usage
import { ContactForm, createContactTransport } from '@rchursin/product/contact'// 1. The page: pick a transport, render the formconst transport = createContactTransport({ provider: 'endpoint', endpoint: '/api/contact' })
<ContactForm transport={transport} email="you@YOUR_DOMAIN" replyWindow="within 2 working days" bookingUrl="https://cal.com/YOUR_HANDLE/20min" note="I use what you send only to reply to you."/>
// Other transports (their IDs are public by design):// createContactTransport({ provider: 'formspree', formspreeId: 'YOUR_FORM_ID' })// createContactTransport({ provider: 'web3forms', web3formsKey: 'YOUR_KEY' })// createContactTransport({ provider: 'none' }) → validates, then offers the visitor's email app
// 2. The server: api/contact.ts ships with the kit. In Next it is src/app/api/contact/route.ts (this site has not wired it yet):// export function POST(request: Request) { return handleContact(request, process.env) }// It checks the origin, size, honeypot and timing, rate-limits, runs validateContact,// then sends a plain-text email through Resend with the submission id as Idempotency-Key.Examples
When the server says no
Choose what the pretend server answers, then press Send message. A field error joins the summary and sits under its field; a failure or rate limit keeps the text and offers the visitor’s email app.
import { useMemo, useState } from 'react'import { ContactForm, type ContactResponse, type ContactTransport } from '@rchursin/product/contact'import { Segmented } from '@rchursin/product/ui'import { SampleBadge } from '@rchursin/product/shell/layout'
type Answer = 'invalid' | 'failed' | 'rate_limited'
/** What the pretend server says back. Real servers answer in the same shape (ContactResponse) */const answers: Record<Answer, ContactResponse> = { // A check only the server can make, e.g. the address has no mail server invalid: { ok: false, code: 'invalid', errors: { email: 'Enter an email address that can receive replies' } }, failed: { ok: false, code: 'failed' }, rate_limited: { ok: false, code: 'rate_limited' },}
export default function ContactFormServerErrors() { const [answer, setAnswer] = useState<Answer>('invalid') const transport = useMemo<ContactTransport>(() => ({ canSend: true, send: () => new Promise((resolve) => window.setTimeout(() => resolve(answers[answer]), 700)) }), [answer])
return (Before sending is set up
With provider “none”, the form still checks everything, then offers to open the message in the visitor’s email app or copy it.
import { ContactForm, createContactTransport } from '@rchursin/product/contact'
/** * provider 'none' (or a key still set to YOUR_KEY) gives a transport with canSend: false. * The form still checks everything, then offers to open the message in the visitor's email * app or copy it. Nothing is sent from the page. */const transport = createContactTransport({ provider: 'none' })
export default function ContactFormNotSetUp() { return ( <div className="w-full max-w-lg"> <ContactForm transport={transport} email="hello@example.com" headingLevel={4} initialValues={{ name: 'Sam Okafor', email: 'sam@example.com', topic: 'speaking', message: 'I host a podcast about developer tools. Would you talk about measuring what coding agents cost?', }} /> </div> )}API
ContactForm props
| Prop | Type | Default | Description |
|---|---|---|---|
transportrequired | ContactTransport | – | How messages leave the page: createContactTransport(…) or your own { canSend, send }. |
emailrequired | string | – | Your public address. Failure messages and the “not set up” panel offer it. |
replyWindow | string | – | When you reply, e.g. "within 2 working days". The sent panel says it; without it, “soon”. |
topics | readonly Topic[] | all of TOPICS | Which topics to offer, in this order, e.g. ["project", "hello"]. Offer only what you’re open to; the server accepts any of TOPICS. |
bookingUrl | string | – | A scheduling link offered after sending (“If it’s urgent, book a call”). For Cal.com and Calendly links the name and email are filled in. |
onSent | (values: ContactValues) => void | – | Called once a message is sent, with what was sent. |
initialValues | Partial<ContactValues> | – | Answers to start with, e.g. a topic chosen from a link. “Send another message” starts from them again, keeping the sender’s name and email. |
note | ReactNode | – | A line under the send button, e.g. what happens to the message. |
getToken | () => Promise<string | undefined> | – | Called right before sending; the result travels as cfToken. Wire Cloudflare Turnstile here if spam gets through. |
headingLevel | 2 | 3 | 4 | 2 | Level of the error summary and sent panel headings, to fit the page outline. |
className | string | – | Extra classes on the form (and on the sent panel that replaces it). |
createContactTransport config
| Prop | Type | Default | Description |
|---|---|---|---|
providerrequired | 'none' | 'endpoint' | 'formspree' | 'web3forms' | – | Where messages go. endpoint is your own function (recommended: the key stays on the server). |
endpoint | string | – | Your function’s URL, e.g. "/api/contact". Same origin, so no CORS setup. |
formspreeId | string | – | Formspree form ID (public). |
web3formsKey | string | – | Web3Forms access key (public by design). |
ContactTransport
| Prop | Type | Default | Description |
|---|---|---|---|
canSendrequired | boolean | – | False for provider "none" or while an ID is still a YOUR_… placeholder. |
sendrequired | (payload: ContactPayload) => Promise<ContactResponse> | – | Sends one message. Answers { ok: true }, or { ok: false, code: "invalid", errors }, "rate_limited" or "failed". Network errors and a 15 second timeout become "failed". |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| TabShiftTab | Moves between fields, the topic group (one stop), the send button and any links that appear. |
| Up arrowDown arrowLeft arrowRight arrow | In the topic group, moves to the next topic and chooses it (native radios). |
| Space | Chooses the focused topic. |
| Enter | In a one-line field, sends the form. In the message, starts a new line. On an error summary link, focuses that field. |
- Every field has a visible label above it; hints and errors are tied to the field with aria-describedby, and a field in error gets aria-invalid and a firmer ring.
- On a failed send, a summary headed “There is a problem” appears at the top and takes focus. Each message links to its field, and “Error: ” is added to the page title until the errors are fixed.
- Errors start with a visually hidden “Error:” and an icon, so they never rely on colour. The topic error sits inside the group’s legend, so it is read with the question.
- The message states its limit to screen readers (“You can enter up to 2,000 characters”). The visible count appears at 1,500 characters; a polite live region repeats it once typing pauses.
- While sending, the button says “Sending…”, sets aria-busy and ignores further presses, but stays focusable (not disabled), so focus is never lost. “Sending your message” is announced through AnnouncerProvider (and “Message copied” after a copy).
- A failure or rate limit appears in a role="alert" region above the button, with the text still in the form. After a send, the “Message sent” heading takes focus.
- The honeypot field sits off-screen inside aria-hidden, with tabindex="-1" and autocomplete="off": people and keyboards never reach it, and browsers are asked not to autofill it. Check your own browser’s autofill once before launch.
Guidelines
Do
- Show your email address next to the form, as text with a copy button: some people would rather write from their own app.
- Promise only a reply window you can keep, and say it before and after sending.
- Ask only what you need to reply. Budget and timeline stay optional and appear only for projects.
- Run the same rules on the server (validateContact) and keep secret keys out of NEXT_PUBLIC_* variables.
Don’t
- Don’t add a CAPTCHA before spam is a real problem; the honeypot, time check and rate limit stop most bots.
- Don’t add a consent checkbox to reply to a message; say what happens to it in one plain line instead.
- Don’t clear the form after an error, or use placeholders as labels.
- Don’t embed a booking calendar on the page; link to it.
In use
Related
- Form toolkituseForm for values, errors and validation timing, plus the checks and GOV.UK-style messages every form in the kit shares.
- Error summaryA “There is a problem” box at the top of a form that takes focus after a failed submit and links each message to its field.
- Radio cardsA radio group drawn as cards, for choices that each need a sentence, where the whole card is the target.
- Character countA textarea that counts down to its limit, GOV.UK style: it never cuts text off, and speaks the count when typing pauses.
- Copy buttonCopy text to the clipboard with one press: a pearl button that says “Copied”, a quiet one for code, and the helper under both.
Source
The component’s own files, exactly as they run on this site.
/** * Contact form: name, email, topic, message (and budget and timeline for project enquiries), * sent through any ContactTransport. Built from the forms kit (useForm, Field, RadioCards, * ErrorSummary) and the rules in ./schema, which the server function runs too. * * const transport = createContactTransport({ provider: 'endpoint', endpoint: '/api/contact' }) * <ContactForm transport={transport} email="you@YOUR_DOMAIN" replyWindow="within 2 working days" /> * * Behaviour (from the research: GOV.UK, Baymard, NN/g): * - Nothing is checked while you type. On blur, a field that has content is checked; once a * field shows an error it is re-checked on every keystroke and clears as soon as it's fixed. * - Send checks everything: an error summary at the top takes focus, every error also sits * under its field, and "Error: " is added to the page title until the errors are fixed. * - Inputs are controlled, so nothing typed is ever lost to a failed send. * - While sending, the button says "Sending…" and repeat presses are ignored. One submission * id per message is reused on retries, so the server can send it once (Idempotency-Key). * - A honeypot field and the time of the first input travel with the message; the server * drops obvious bots silently (see looksAutomated in ./schema). * - Sent: a panel replaces the form and takes focus. Not set up (transport.canSend false): * the message can be opened in the visitor's email app or copied instead.