Skip to content
AstroCraft Docs
On this theme

Contact Form

/contact/ is the only page in Grafio that is rendered per request. It receives the form POST and re-renders itself with the result, which is why it carries:

export const prerender = false;

Everything else builds to static HTML, so your host’s server function only ever runs this one page.

That single line has a consequence worth internalising early: astro dev always runs a server, adapter or not. A working form in development proves nothing about a static build. Run pnpm build before you trust it.

How it works

The form is a native <form method="POST"> bound to an Astro action with accept: "form". That choice is the reason the whole thing works with JavaScript disabled: the browser posts real FormData, the server validates and re-renders, and the success and error states exist in exactly one place — the server render.

The flow, hop by hop:

  1. Sections/Contact/Form.astro renders the fields, stamps a hidden timestamp, and reads any previous result via Astro.getActionResult(actions.contact).
  2. src/actions/index.ts receives the POST. Astro has already run the Zod schema, so the handler starts with valid data.
  3. The handler checks the spam gates, then Turnstile if configured, then that the mail keys exist.
  4. src/js/contact.ts builds the email body with HTML escaping.
  5. A plain fetch posts it to Resend. No SDK.
  6. The action returns, and Form.astro renders the success block or the error.

With view transitions on, the router intercepts the submit and swaps the response in. With them off — or with JavaScript off — the browser does a full POST and re-render. Same server render either way.

What you must configure

Two environment variables, or the form validates correctly and then tells the visitor it is not configured:

RESEND_API_KEY=re_xxxxxxxxxxxx
CONTACT_TO_EMAIL[email protected]

A third is optional but usually necessary:

CONTACT_FROM_EMAIL[email protected]

It defaults to [email protected], Resend’s shared sender. That works immediately with no domain setup, but it can only deliver to the address that owns the Resend account. If CONTACT_TO_EMAIL is any other address, the send succeeds at your end and the mail never arrives. Verify your own domain in Resend and set this to send anywhere.

Two more are optional, and they are both or neither:

TURNSTILE_SITE_KEY=…
TURNSTILE_SECRET_KEY=…

Note that .env.example ships listing only the three Resend variables. The Turnstile keys and SITE_URL are documented here and in astro.config.mjs rather than in that file.

Validation

The trust boundary is one Zod schema in src/js/contact.ts:

export const contactSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1)
    .max(100)
    .refine((value) => !/[\r\n]/.test(value), "Line breaks are not allowed here."),
  email: z.email("Please enter a valid email address.").max(254),
  message: z.string().trim().min(10, "Tell me a little more than that.").max(5000),
  _gotcha: z.string().optional(),
  _ts: z.coerce.number().int().positive(),
  "cf-turnstile-response": z.string().optional(),
});

Every field is also required in the markup, and email uses type="email", so the browser validates first when JavaScript is on. There is no novalidate. The server re-validates regardless — client validation is a convenience, never a control.

The CRLF refinement on name is a header-injection guard: name is the only field that reaches a mail header, via the subject line. It rejects rather than strips, so the visitor sees why.

Anti-spam

Three layers, working from cheapest to strongest.

A honeypot. A field named _gotcha, positioned off-screen rather than display: none — some bots skip what a human cannot see, but fewer skip what is merely off-canvas:

<div class="absolute -left-[9999px] h-px w-px overflow-hidden" aria-hidden="true">
  <label for="_gotcha">Leave this field empty</label>
  <input id="_gotcha" type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
</div>

It is permissive in the schema on purpose, so a filled honeypot becomes a readable form-level rejection rather than a validation error on an invisible input.

A time gate. The render timestamp goes into a hidden _ts field and the handler compares it to the current time:

export const MIN_FILL_MS = 3000;

export function spamReason({ _gotcha, _ts }, now = Date.now()): string | null {
  if (_gotcha) return "Message rejected.";
  if (now - _ts < MIN_FILL_MS) return "That was quick — please take another look and resend.";
  return null;
}

Server clock to server clock, so it does not trust the visitor’s device. There is no upper bound — a form left open for an hour still submits. And _ts is a plain hidden field, so it is forgeable: this stops drive-by bots, not a targeted attacker who reads the markup and waits three seconds.

Cloudflare Turnstile, optional. Enforcement turns on only when both keys are present:

const configured = Boolean(TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);

if (!configured && (TURNSTILE_SITE_KEY || TURNSTILE_SECRET_KEY)) {
  console.warn("[turnstile] Ignoring a half-configured setup … Turnstile stays OFF.");
}

Setting one key logs a warning and leaves verification off. That is deliberate: a half-configured challenge that blocks real visitors is worse than no challenge.

Know the trade-off before you enable it. The Turnstile widget requires JavaScript, so setting the keys makes your contact form JavaScript-only. Leaving them unset keeps the no-JS path, which the honeypot and time gate still cover.

On Netlify there is a fourth layer, in netlify.toml: five requests per sixty seconds per IP. It is the backstop behind Turnstile, not a replacement — Turnstile judges whether a submission looks human, the rate limit caps how many any client can make regardless of the answer.

Every error message

Field-level messages appear under their field with aria-describedby wired. Everything else lands in one role="alert" paragraph.

  • Please enter a valid email address. / Tell me a little more than that. / Line breaks are not allowed here. — validation.
  • Message rejected. — the honeypot was filled.
  • That was quick — please take another look and resend. — submitted under three seconds after render.
  • Please complete the human check and resend. — Turnstile is on and no token was submitted.
  • We couldn't verify that you're human. Please try again. — Cloudflare was unreachable or replied unparseably.
  • This form's spam protection is misconfigured. Please email me directly. — a bad Turnstile secret.
  • That human check didn't pass. Please try again. — Turnstile said no.
  • This form is not configured yet. Set RESEND_API_KEY and CONTACT_TO_EMAIL — see .env.example. — the mail keys are missing. That check runs at request time, so a missing key never breaks your build.
  • Your message could not be sent. Please email me directly. — Resend was unreachable, timed out at ten seconds, or rejected the message. The provider’s actual response is logged server-side but never shown, because it can name the account.

Success renders Message sent. in a role="status" block, and a script moves focus to it after the swap.

One UX fact worth knowing before a visitor discovers it: on a validation error the fields re-render empty. No value is passed back, and the router replaces the body, so typed content is lost on a rejected submit.

Adding a field

Four places, in this order.

1. The schema in src/js/contact.ts — add the key and its constraints.

2. The FIELDS array in Form.astro:

{
  name: "company",
  index: "04",
  label: "Where do you work?",
  placeholder: "Acme Inc.",
  autocomplete: "organization",
  as: "Input",
  control: { type: "text" },
  class: "",
}

A name that is not a schema key fails astro check, because the error lookup is keyed off the inferred schema type.

3. buildEmail in contact.ts — and this is the one that will catch you out. The handler passes all fields through, but buildEmail is typed to pick only name, email and message. A new field validates, passes the spam gates, and is then silently dropped from the email. Add it to the template, and escape it:

`<p><strong>Company:</strong> ${escapeHtml(company)}</p>`;

4. src/js/contact.test.ts — one runnable check per non-trivial change is the house convention, and pnpm test fails if it finds no checks at all.

Two styling traps

Do not put a colour into a field’s class override. A consumer class merges last, so a border colour in the shared underline-field recipe would beat the border-error that state="error" sets — and the error state would render as an ordinary underline while looking correctly wired. Style shape in your override; leave colour to _field.ts.

Do not preventDefault() the submit. The view-transition router bails on a defaulted event, and intercepting it means hand-building a second copy of every state the server already renders. The shipped script only disables the button and moves focus.

Removing the form

Delete src/pages/contact.astro, src/actions/index.ts, src/js/contact.ts, src/js/turnstile.ts, src/components/Sections/Contact/, and the contact entries in navData.json.ts and pageCopy.json.ts.

Then remove the adapter from astro.config.mjs. With no server route left, the site is fully static and deploys anywhere.

NEXT STEPImages & Assets