Notes from production
FormsWeb3FormsServerless

How the /book Adopt-Assessment form works

The /book page has one lead form — the Adopt Assessment. But this site is static (SSG): every page is pre-built HTML served from a CDN, with no server of our own. So where does a submission go? Here's how it works, down to the details.

The problem: a static site has no server

This site is prerendered at build and served as static files from Cloudflare's edge. Fast and cheap — but it means there's no backend endpoint to receive a form POST. The classic approach (a form posting to a server script) isn't available. We need somewhere else to catch the submission.

The fix: Web3Forms (a form backend as a service)

The form POSTs straight from the browser to Web3Forms, a service that accepts the submission and forwards it to your inbox. There's no server of ours in the middle — just a client-side fetch:

const res = await fetch('https://api.web3forms.com/submit', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
  body: JSON.stringify({
    access_key: WEB3FORMS_KEY, // public — only routes to the inbox, reads nothing
    name, email, phone, company, about,
    botcheck: false,           // honeypot (below)
    ...attribution,            // utm_*, referrer, landing_page — cookieless
  }),
})
const data = await res.json()
if (data.success) {
  // show "sent", reset the form, fire the generate_lead event
}
1 · form on /bookstatic HTML from the CDN — no server of oursfetch · POST JSON2 · api.web3forms.comaccepts the submission — not our backendhoneypot + key check3 · emails your inboxone lead = one email4 · successGA4 generate_lead (cookieless) · form: "sent"no server of ours in this flow — just browser → Web3Forms
The submission never touches a server of ours: the browser POSTs straight to Web3Forms, which emails it and returns a success JSON — and that's when we record the conversion signal.

The access key is public — and that's fine

The access_key is committed to the repo, visible in the bundle. That's not a leak: a Web3Forms access key only routes a submission to the configured inbox. It can't read data, can't sign in, can't sign anything — at worst, someone could send submissions to the same inbox (which is what the honeypot + Web3Forms' rate limits are for; rotate the key if it's ever abused). It's an important distinction: not every long string is a secret. A secret is something that can read or change something.

Spam protection: a honeypot

With no server, we can't run our own checks — so the form plants a honeypot: one hidden field (botcheck) positioned off-screen and hidden from screen readers. Humans never see it, so never fill it; many bots fill every field they find. Web3Forms rejects any submission whose honeypot field is filled.

{/* honeypot — humans never see it; a bot that fills it → rejected */}
<input type="text" name="botcheck" tabIndex={-1} aria-hidden="true"
  style={{ position: 'absolute', left: '-9999px', opacity: 0 }} />

Cookieless attribution

When a visitor first lands, the form stores first-touch attributionutm_*, referrer, and the landing page — to sessionStorage, then attaches it to every submission. This runs with no cookie and no consent prompt: sessionStorage isn't a cookie, lives only for the tab, and doesn't track you across sites. So a lead arrives with its source attached — which campaign, which referrer — without touching consent.

The conversion signal: generate_lead

When Web3Forms returns success, the form fires the GA4 generate_lead event (via gtag) carrying the first-touch utm_*. If VITE_GA_ID isn't set, track() is a no-op — so the form works fine with no analytics at all. And under Consent Mode v2 'denied', GA4 still sends that event cookieless (no client-id), so we respect consent without losing the aggregate signal.

State & accessibility

The form has four states — idle · sending · sent · error. The button swaps its label and is disabled while sending (preventing a double-submit). Results are announced to assistive tech: success uses role="status", errors and the phone-validation error use role="alert". Every field has a <label htmlFor>, and the phone input ties its error in with aria-invalid + aria-describedby. The number is validated client-side (^\d{6,15}$) before anything is sent.

CSP: why fetch, not a form action

This site locks down a Content-Security-Policy with no 'unsafe-inline'. Two directives decide where the form may talk:

Common questions

Is the Web3Forms access key a secret?

No — it's public by design and safe to commit. The access key only routes a submission to the configured inbox; it can't read data, access the account, or sign anything. At worst someone could send submissions to the same inbox — which the honeypot plus Web3Forms' rate limits handle, and the key can be rotated if abused.

How is spam handled without a CAPTCHA?

With a honeypot: one hidden field (botcheck) a human never sees or fills, but many bots fill automatically. A submission with it filled is rejected. Web3Forms also runs server-side spam checks and rate limits. For higher volume, a frictionless CAPTCHA like Cloudflare Turnstile can be added.

Does the UTM/referrer tracking need cookie consent?

No. Attribution is stored in sessionStorage (not a cookie, lives only for the tab, never cross-site) and attached to the submission — so no consent is required. The GA4 generate_lead event is also sent cookieless under Consent Mode 'denied', so the conversion signal still respects the user's choice.

Where this fits

This pattern leans on the static hosting from CI/CD: GitHub Actions → Cloudflare Pages and the security baseline from SSL & security on the edge (whose CSP allows this connect-src). The form itself lives at /book.

Sources

  1. Web3Forms — documentation
  2. MDN — Fetch API
  3. MDN — CSP connect-src
  4. GA4 — generate_lead